mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
263 lines
11 KiB
Lua
263 lines
11 KiB
Lua
-- The super effective / not very effective hit sounds played at the wrong
|
|
-- pitch, so the weak-sounding hit landed on the weakness (#826). pokered
|
|
-- PlayApplyingAttackSound (engine/battle/animations.asm) sets
|
|
-- wFrequencyModifier with the sound, and audio/engine_2.asm
|
|
-- Audio2_ApplyFrequencyModifier adds it to the noise channel's polynomial
|
|
-- counter. Ears only, so never under POKEPORT_SPEED -- the pitch is the test.
|
|
-- POKEPORT_DRIVER=tests/drivers/hit_sfx_bug826_test.lua POKEPORT_IDENTITY=bug826 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
|
return function(game)
|
|
local U = dofile("tests/drivers/util.lua")
|
|
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
|
local Pokemon = require("src.pokemon.Pokemon")
|
|
local BattleState = require("src.battle.BattleState")
|
|
local ChipSynth = require("src.core.ChipSynth")
|
|
local TypeChart = require("src.battle.TypeChart")
|
|
local Sound = require("src.core.Sound")
|
|
|
|
-- GOLEM is ROCK/GROUND, so one attacker covers both ends of the routine
|
|
-- with no switching: WATER_GUN is 2x on each type and EMBER is 0.5x on
|
|
-- ROCK. SPLASH on the foe keeps the lead alive for as many replays as
|
|
-- the listener wants.
|
|
local FOE, FOE_LEVEL = "GOLEM", 60
|
|
local LEAD, LEAD_LEVEL = "BULBASAUR", 25
|
|
local WEAK_MOVE, STRONG_MOVE = "EMBER", "WATER_GUN"
|
|
-- PlayApplyingAttackSound's wFrequencyModifier per sound, and the
|
|
-- polynomial-counter byte of each program's first note (audio/sfx/
|
|
-- {damage,super_effective,not_very_effective}.asm) before and after it.
|
|
local SOUNDS = {
|
|
{ name = "Damage", pitch = 0x20, raw = 0x44, want = 0x64 },
|
|
{ name = "Super_Effective", pitch = 0xe0, raw = 0x34, want = 0x14 },
|
|
{ name = "Not_Very_Effective", pitch = 0x50, raw = 0x55, want = 0xa5 },
|
|
}
|
|
|
|
local pass, fail = 0, 0
|
|
local function check(label, ok)
|
|
if ok then pass = pass + 1 else fail = fail + 1 end
|
|
U.log(ok and "PASS" or "FAIL", label)
|
|
return ok
|
|
end
|
|
local function hex(v) return v and ("$%02x"):format(v) or "nil" end
|
|
|
|
-- ---- data the moment depends on ----------------------------------------
|
|
local sfx = game.data.audio and game.data.audio.sfx or {}
|
|
for _, row in ipairs(SOUNDS) do
|
|
local def = sfx[row.name]
|
|
check(row.name .. " resolves to a chip program in the generated audio",
|
|
type(def) == "table" and def.address ~= nil and def.bank ~= nil)
|
|
end
|
|
local moveWeak, moveStrong = game.data.moves[WEAK_MOVE], game.data.moves[STRONG_MOVE]
|
|
local foeDef = game.data.pokemon[FOE]
|
|
check(WEAK_MOVE .. " and " .. STRONG_MOVE .. " resolve in the move table",
|
|
moveWeak ~= nil and moveStrong ~= nil)
|
|
check(FOE .. " resolves with a dual type", foeDef ~= nil and #foeDef.types == 2)
|
|
local weakMult, strongMult
|
|
if moveWeak and moveStrong and foeDef then
|
|
weakMult = TypeChart.effectiveness(moveWeak.type, foeDef.types)
|
|
strongMult = TypeChart.effectiveness(moveStrong.type, foeDef.types)
|
|
U.log(("%s on %s is x%.1f, %s is x%.1f (the x10 scale Damage.lua uses)")
|
|
:format(WEAK_MOVE, FOE, weakMult / 10, STRONG_MOVE, strongMult / 10))
|
|
end
|
|
check(WEAK_MOVE .. " is the resisted side of the pair", (weakMult or 10) < 10)
|
|
check(STRONG_MOVE .. " is the super effective side", (strongMult or 10) > 10)
|
|
|
|
local vol = game.save.options and game.save.options.sfxVol
|
|
check("sfx volume is up (" .. tostring(vol) .. "/7)", (vol or 0) > 0)
|
|
if (vol or 0) == 0 then
|
|
U.log("with sfxVol 0 both hits are silent and this run proves nothing;",
|
|
"raise it in OPTION and start over")
|
|
end
|
|
|
|
-- ---- the synth half: does the modifier reach the noise channel? ---------
|
|
-- Sample each program once at offset 0 and again at its own modifier. A
|
|
-- port that drops wFrequencyModifier reports the same byte twice, which is
|
|
-- the whole of #826: unpitched, Super_Effective ends duller than
|
|
-- Not_Very_Effective ends.
|
|
local function firstNoise(header, offset)
|
|
if not header then return nil end
|
|
local engine = ChipSynth.newEngine(game.data, header, {
|
|
sfx = true, allowLoops = false, frequencyOffset = offset,
|
|
})
|
|
for _, channel in ipairs(engine.channels) do
|
|
channel:sample()
|
|
local event = channel.event
|
|
if event and event.noiseParameter then return event.noiseParameter end
|
|
end
|
|
return nil
|
|
end
|
|
for _, row in ipairs(SOUNDS) do
|
|
local bare = firstNoise(sfx[row.name], 0)
|
|
local pitched = firstNoise(sfx[row.name], row.pitch)
|
|
check(("%s reads NR43 %s unmodified, as in the asm")
|
|
:format(row.name, hex(row.raw)), bare == row.raw)
|
|
check(("...and %s once %s is applied"):format(hex(row.want), hex(row.pitch)),
|
|
pitched == row.want)
|
|
U.log(("%s: %s -> %s, shift clock %d -> %d (higher shift = duller)")
|
|
:format(row.name, hex(bare), hex(pitched),
|
|
math.floor((bare or 0) / 16), math.floor((pitched or 0) / 16)))
|
|
end
|
|
|
|
-- ---- the battle half: what does a hit row actually carry? ---------------
|
|
-- Offscreen scratch turn, no animation timing in the way. The row has to
|
|
-- name the sound AND its modifier byte, and must not carry a tempo byte:
|
|
-- Audio2_note_length skips Audio2_SetSfxTempo on CHAN8 (`cp CHAN8 / jr z,
|
|
-- .skip`), so the hardware never retimes these three.
|
|
-- the move is handed in whole, so the party lead keeps both its slots for
|
|
-- the live battle below
|
|
local function rowSfx(moveId)
|
|
local scratch = BattleState.newWild(game, FOE, FOE_LEVEL)
|
|
scratch.onFinish = function() end
|
|
-- Damage.accuracyRoll is `rng(0, 255) < acc` (src/battle/Damage.lua:105),
|
|
-- so the roll has to be pinned LOW to guarantee a hit. Pinning it high
|
|
-- misses every time, the row never gets a .hit, and this scan reads nil.
|
|
scratch.rng = function(lo) return lo end
|
|
scratch:performMove(scratch.player, scratch.enemy, { id = moveId, pp = 20 })
|
|
for _, row in ipairs(scratch.queue) do
|
|
if row.hit and row.hit.sfx then return row.hit.sfx end
|
|
end
|
|
return nil
|
|
end
|
|
do
|
|
local lead = Pokemon.new(game.data, LEAD, LEAD_LEVEL)
|
|
lead.moves = {
|
|
{ id = WEAK_MOVE, pp = 25, maxPP = 25 },
|
|
{ id = STRONG_MOVE, pp = 25, maxPP = 25 },
|
|
}
|
|
game.save.party = { lead }
|
|
for _, case in ipairs({
|
|
{ move = STRONG_MOVE, want = "Super_Effective", pitch = 0xe0 },
|
|
{ move = WEAK_MOVE, want = "Not_Very_Effective", pitch = 0x50 },
|
|
}) do
|
|
local row = rowSfx(case.move)
|
|
check(case.move .. " queues a hit sound with its modifier",
|
|
type(row) == "table" and row.sound == case.want
|
|
and row.pitch == case.pitch)
|
|
check("...and no tempo byte, the way CHAN8 ignores one",
|
|
type(row) == "table" and row.tempo == nil)
|
|
U.log(("%s -> %s pitch %s"):format(case.move,
|
|
type(row) == "table" and tostring(row.sound) or tostring(row),
|
|
type(row) == "table" and hex(row.pitch) or "nil"))
|
|
end
|
|
end
|
|
|
|
-- ---- reach the moment ---------------------------------------------------
|
|
-- ROUTE_1 is open field (data/generated/maps.lua ROUTE_1); the stand cell
|
|
-- is read back off the loaded map, and a map edit degrades to the first
|
|
-- free cell instead of dropping the player into a wall.
|
|
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
|
U.wait(10)
|
|
local map = game.overworld.map
|
|
if not map:isWalkableCell(5, 5) then
|
|
local fx, fy
|
|
for cy = 0, map.heightCells - 1 do
|
|
for cx = 0, map.widthCells - 1 do
|
|
if map:isWalkableCell(cx, cy) then fx, fy = cx, cy break end
|
|
end
|
|
if fx then break end
|
|
end
|
|
if fx then
|
|
U.log("cell (5, 5) is not walkable, standing on", fx, fy)
|
|
U.teleport(game, "ROUTE_1", fx, fy, "down")
|
|
U.wait(10)
|
|
end
|
|
end
|
|
local ow = game.overworld
|
|
check("player stands on a walkable ROUTE_1 cell",
|
|
ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY))
|
|
|
|
-- listen in on the real playback path so the log can tell "the fix is not
|
|
-- wired to the battle" from "the fix is wired but you did not like it"
|
|
local heard = {}
|
|
local realPlayMove = Sound.playMove
|
|
Sound.playMove = function(data, anim)
|
|
if type(anim) == "table" and anim.sound then
|
|
for _, row in ipairs(SOUNDS) do
|
|
if anim.sound == row.name then
|
|
heard[#heard + 1] = { sound = anim.sound, pitch = anim.pitch }
|
|
end
|
|
end
|
|
end
|
|
return realPlayMove(data, anim)
|
|
end
|
|
|
|
local function mashUntil(cond, max)
|
|
for _ = 1, max or 160 do
|
|
if cond() then return true end
|
|
U.tap(game, "a")
|
|
U.wait(4)
|
|
end
|
|
return cond()
|
|
end
|
|
|
|
local function newFight()
|
|
local battle = BattleState.newWild(game, FOE, FOE_LEVEL)
|
|
battle.onFinish = function(result) ow:afterBattle(result, battle) end
|
|
-- SPLASH so the foe's turn cannot end the run, or drown the hit under a
|
|
-- damage sound of its own
|
|
battle.enemy.mon.moves = { { id = "SPLASH", pp = 40, maxPP = 40 } }
|
|
battle.enemy.curMoves = battle.enemy.mon.moves
|
|
ow:pushBattle(battle)
|
|
U.wait(220) -- the send-out intro plays before the menu is reachable
|
|
mashUntil(function() return battle.phase == "menu" end)
|
|
return battle
|
|
end
|
|
|
|
local battle = newFight()
|
|
check("the wild " .. FOE .. " battle reached its FIGHT menu",
|
|
battle.phase == "menu")
|
|
U.shot(game, DIR .. "/bug826_menu.png")
|
|
|
|
-- slot 1 first: the resisted hit, so the pair is heard weak then strong
|
|
local function swing(slotDown, label)
|
|
local before = #heard
|
|
U.tap(game, "a") -- FIGHT
|
|
U.wait(16)
|
|
if slotDown then U.tap(game, "down"); U.wait(8) end
|
|
U.tap(game, "a")
|
|
for frame = 1, 1200 do
|
|
if battle.phase == "menu" and #battle.queue == 0 and not battle.draining then
|
|
break
|
|
end
|
|
if not battle.draining and frame % 8 == 0 then U.tap(game, "a") end
|
|
U.wait(1)
|
|
end
|
|
local row = heard[before + 1]
|
|
U.log(("%s played %s at pitch %s"):format(label,
|
|
row and row.sound or "nothing",
|
|
row and hex(row.pitch) or "nil"))
|
|
return row
|
|
end
|
|
|
|
local weakHeard = swing(false, WEAK_MOVE)
|
|
U.shot(game, DIR .. "/bug826_not_very_effective.png")
|
|
local strongHeard = swing(true, STRONG_MOVE)
|
|
U.shot(game, DIR .. "/bug826_super_effective.png")
|
|
check("the resisted hit reached the mixer as Not_Very_Effective $50",
|
|
weakHeard ~= nil and weakHeard.sound == "Not_Very_Effective"
|
|
and weakHeard.pitch == 0x50)
|
|
check("the super effective hit reached it as Super_Effective $e0",
|
|
strongHeard ~= nil and strongHeard.sound == "Super_Effective"
|
|
and strongHeard.pitch == 0xe0)
|
|
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
|
|
|
|
-- ---- hand the pad over --------------------------------------------------
|
|
Sound.playMove = realPlayMove
|
|
if battle.phase ~= "menu" then
|
|
U.log("(the menu did not come back on its own: mash A to reach FIGHT)")
|
|
end
|
|
U.log("Both hits have already sounded once. GOLEM is still standing and")
|
|
U.log("EMBER and WATER_GUN sit in slots 1 and 2, so play them back to back")
|
|
U.log("as often as you like.")
|
|
U.log("WATER_GUN, under \"It's super effective!\", should be the brighter")
|
|
U.log("and sharper of the two -- a high crack. EMBER, under \"It's not very")
|
|
U.log("effective...\", should be a low dull rumble underneath it.")
|
|
U.log("The near miss to listen for: the two are close in brightness, or the")
|
|
U.log("crack lands on EMBER and the thud on WATER_GUN. That is the modifier")
|
|
U.log("going missing again, and it is what #826 sounded like.")
|
|
U.log("The neutral hit changed too: any move that is neither, on any foe,")
|
|
U.log("is now a shade duller than it used to be, and that is correct.")
|
|
|
|
while true do
|
|
coroutine.yield()
|
|
end
|
|
end
|