Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/gba-damage-base-contract.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: ordinary GBA base damage
on:
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: gba-damage-base-${{ github.ref }}
cancel-in-progress: true
jobs:
contract:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v7
with:
repository: bryanthaboi/gen1recomp
ref: 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91
path: .contract-reference/gen1
- name: Install runtime
run: sudo apt-get update && sudo apt-get install -y luajit
- name: Check GBA damage and older-profile retention
run: luajit tests/engine/gba_damage_base.lua
- name: Compare actual ordinary damage calculators
run: |
mkdir -p /tmp/gba-damage-base
python3 tools/gba-damage-base-contract-check/check.py --gen1 .contract-reference/gen1 --report /tmp/gba-damage-base/records.json
- uses: actions/upload-artifact@v6
if: always()
with:
name: gba-damage-base
path: /tmp/gba-damage-base
if-no-files-found: ignore
70 changes: 70 additions & 0 deletions docs/gba-base-damage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Ordinary GBA base-damage contract

The shared damage calculator still applied earlier generations' base-damage
steps to the GBA profile. This could quarter wide stats, cap a large base at
997, halve the attack stat for burn, and apply weather after the final +2.
These steps produce different GBA results at rounding boundaries.

The GBA profile now selects `baseDamageStyle = "gba"`. It uses wide stats
without the older base cap and applies burn, screens, spread and weather to
the truncated base before +2. Its physical zero base becomes one; the special
base can remain zero. Solar Beam's bad-weather penalty follows the weather
type modifier, and weather does not apply the older minimum-one clamp.
Profiles without this opt-in retain their existing calculations.

For example, at level 1 with power 2, equal stats and STAB, an ordinary
physical move deals 4 damage at roll 100 rather than 3. At level 30, Fire
power 35, equal stats, STAB and rain, the base produces 9 damage rather than
7. These are noncritical calculations; critical/type rounding is addressed
separately by PR #175.

## Shared calculator checks

The same case file invokes Gen1Recomp's actual GBA `Damage.calc` and
Gen2Recomped's actual `Damage.compute`. It uses equal typed session stats,
move records, status/screen/weather flags and pinned noncritical rolls. No
copy of either damage formula supplies the expected result.

The matrix covers FireRed and Emerald, 18 contexts, six levels, four powers,
four Attack/Defense pairs and rolls 85/100: **4 workers / 6,912 paired numeric
observations**. Contexts include STAB, no STAB, burn, single/double screens,
spread with screens, resistance, immunity, rain/sun, and Solar Beam in rain,
sand and hail. Both calculators preserve the supplied Pokemon HP.
Six additional host checks preserve the older profiles' minimum base,
paired stat scaling and base cap. The host suite has **20,742 checks**.

```sh
luajit tests/engine/gba_damage_base.lua
python3 tools/gba-damage-base-contract-check/check.py --gen1 /path/to/gen1
```

The reference must be clean at
`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. Each worker loads its own actual
engine modules. The workflow retains its comparison report. No ROM or
cartridge-derived assets are needed or included.

This contract covers ordinary noncritical base damage with the listed
inputs. It does not establish every move effect, ability, held-item rule,
critical rule, ordinary turn/controller, or a full graphical playthrough.
The unfinished enemy switch/item hook and AI simulation wiring are outside
this PR.

## Validation record

At clean feature runtime `a9a3cbf2b4973865378649a980cbd3c5143c672b`:

- **20,742/20,742** host checks pass, including six Gen-1/Gen-2 retention checks.
- Both native calculators match in **4 workers / 6,912 paired observations**,
with zero differences against clean Gen1Recomp `535a5ec`.
- Clean upstream `13af468b97dc37015d14ad5c71d7f067c3ee810d` differs in
**2,209 numeric observations per GBA profile** on the same matrix.
- The complete feature run passes **65/75** engine suites, **3/3** SDK suites
and B30. Upstream passes **64/74**; all **27 FAIL lines across ten baseline
suites** match exactly. Workflow lint and formatting of the new Lua files pass.

Evidence is retained in the ignored `tmp/gba-base-qa/` directory:
`targeted.log`, `contracts-clean.json`, `full.log` and `parent-differences.json`.
The unchanged upstream full-run baseline is recorded under
`../gen2recomped-ai-random/tmp/gen3-ai-random-qa/main-baseline-full.log`.
Hosted CI is separate from local validation. No new graphical gameplay run
or complete-playthrough claim is made.
42 changes: 37 additions & 5 deletions src/battle/Damage.lua
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,8 @@ function Damage.compute(ruleset, attacker, defender, move, opts)
end

local special = categoryOf(move) == "special"
local gbaBase = ruleset.baseDamageStyle == "gba"
local burnAfterBase, gbaScreen
-- Gen 2 kept the same type-based physical/special split but gave the
-- attacker Sp.Atk and the defender Sp.Def. A Gen 1 stat block has
-- neither key, so it stays on the single `special`.
Expand Down Expand Up @@ -323,7 +325,11 @@ function Damage.compute(ruleset, attacker, defender, move, opts)
-- penalty into a bonus: it ignores the drop AND hits half again as hard
if penalty and penalty.stat == atkStat and not attacker.hazeStatReset
and not Abilities.ignoresBurnDrop(attacker) then
atk = math.max(1, math.floor(atk / penalty.div))
if gbaBase and record.id == "BRN" then
burnAfterBase = penalty.div
else
atk = math.max(1, math.floor(atk / penalty.div))
end
end
-- screens double the effective defense (crits bypass them). The
-- confusion self-hit is the quirk case: HandleSelfConfusionDamage
Expand All @@ -337,7 +343,9 @@ function Damage.compute(ruleset, attacker, defender, move, opts)
if screens then
local up = (special and screens.lightScreen)
or (not special and screens.reflect)
if up then
if up and gbaBase then
gbaScreen = true
elseif up then
-- A SCREEN IS WEAKER WHEN TWO ARE STANDING BEHIND IT.
--
-- 0806_9B48 (REFLECT) and 0806_9C88 (LIGHT SCREEN) check
Expand Down Expand Up @@ -391,7 +399,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts)
end
-- GetDamageVars .scaleStats: when either stat no longer fits a byte,
-- BOTH are quartered (losing low bits), each bumped to at least 1
if atk > 255 or dfn > 255 then
if not gbaBase and (atk > 255 or dfn > 255) then
atk = math.max(1, math.floor(atk / 4))
dfn = math.max(1, math.floor(dfn / 4))
end
Expand Down Expand Up @@ -420,6 +428,13 @@ function Damage.compute(ruleset, attacker, defender, move, opts)
end
d = math.floor(math.floor(d * power * atk / math.max(1, dfn)) / 50)

if gbaBase then
if burnAfterBase then d = math.floor(d / burnAfterBase) end
if gbaScreen then
d = opts.doublesScreens and 2 * math.floor(d / 3) or math.floor(d / 2)
end
end

-- A MOVE THAT HITS BOTH OF THEM DOES HALF TO EACH.
--
-- The cartridge applies this inside CalculateBaseDamage -- after the burn
Expand All @@ -432,7 +447,24 @@ function Damage.compute(ruleset, attacker, defender, move, opts)
d = math.floor(d * ruleset.spreadNum / ruleset.spreadDen)
end

d = math.min(d, 997) + 2
if gbaBase then
-- Physical CalculateBaseDamage raises a zero base to one. Special
-- damage has no equivalent minimum before the final +2.
if not special then d = math.max(1, d) end
if special and not opts.typeless and opts.weather then
-- GBA applies each weather step to the base without Gen-2's minimum
-- one/clamp. Solar Beam's penalty follows the type-weather step.
local modifier = Weather.modifier(opts.weather, move.type)
if modifier then d = math.floor(d * modifier / 10) end
if (opts.weather == "RAIN" or opts.weather == "SAND" or opts.weather == "SANDSTORM" or opts.weather == "HAIL")
and (move.gen3Effect == 151 or move.effect == "SOLARBEAM_EFFECT") then
d = math.floor(d / 2)
end
end
d = d + 2
else
d = math.min(d, 997) + 2
end

-- Held type boosters live inside BattleCommand_DamageCalc, before weather,
-- STAB and effectiveness. This placement matters because every stage floors
Expand All @@ -447,7 +479,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts)
-- as damage * modifier / 10. The confusion self-hit reaches
-- CalculateDamage directly and never runs `stab`, so the typeless path
-- skips weather along with STAB and the type chart.
if not opts.typeless and opts.weather then
if not gbaBase and not opts.typeless and opts.weather then
d = Weather.applyModifier(d, opts.weather, move.type, move.effect)
end

Expand Down
3 changes: 3 additions & 0 deletions src/battle/rulesets/gen3_emerald.lua
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
return {
name = "gen3_emerald",

-- Gen 3 uses wide stats and applies burn/screens/weather before the +2.
baseDamageStyle = "gba",

-- The 1/256 miss is GONE. Gen 1 rolled rand(0..255) against
-- floor(acc * 255 / 100), so a 100%-accurate move still failed on a roll of
-- 255. Gen 3 checks accuracy as a percentage out of 100 and a move at 100%
Expand Down
35 changes: 35 additions & 0 deletions tests/engine/gba_damage_base.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
for _, version in ipairs({ "firered", "emerald" }) do
dofile("tests/gba_damage_base/cases.lua").run("gen2", version)
end
-- Profiles without the GBA opt-in retain their existing arithmetic.
local T = require("tests.modkit")
local D = require("src.battle.Damage")
local rules = require("src.battle.rulesets.gen1_faithful")
for _, generation in ipairs({ 1, 2 }) do
local data = {
constants = { generation = generation },
items = {},
type_chart = { types = { NORMAL = { category = "physical" } }, matchups = {} },
}
require("src.battle.TypeChart").load(data)
local function oldDamage(level, power, attack, defense)
return D.compute(
rules,
{ mon = { level = level, hp = 400 }, curStats = { attack = attack }, curTypes = { "NORMAL" } },
{ mon = { hp = 400 }, curStats = { defense = defense }, curTypes = { "NORMAL" } },
{ id = "BASE_RETENTION", power = power, type = "NORMAL" },
{
forceCrit = false,
data = data,
rng = function()
return 255
end,
}
)
end
T.eq(oldDamage(1, 2, 50, 50), 3, "older minimum-base arithmetic retained")
T.eq(oldDamage(30, 35, 300, 50), 94, "older paired stat scaling retained")
T.eq(oldDamage(30, 250, 500, 10), 1498, "older base damage cap retained")
end
T.finish("ordinary GBA base damage and older-profile retention")
139 changes: 139 additions & 0 deletions tests/gba_damage_base/cases.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
-- Compare the actual ordinary GBA damage calculators with equal session inputs.
local C = {}
function C.run(backend, version)
local T = require("tests.modkit")
require("src.core.GameVersion").set(version)
local native = backend == "gen1"
local D = require(native and "src.core.game3.battle.damage" or "src.battle.Damage")
local rules = not native and require("src.battle.rulesets.gen3_emerald")
local ids = { NORMAL = 0, GHOST = 7, STEEL = 8, FIRE = 10, WATER = 11, GRASS = 12 }
local data = {
items = {},
constants = { generation = 3 },
type_chart = {
types = {},
matchups = {
{ attacker = "NORMAL", defender = "GHOST", multiplier = 0 },
{ attacker = "NORMAL", defender = "STEEL", multiplier = 5 },
},
},
}
for name, id in pairs(ids) do
data.type_chart.types[name] = { id = name, index = id, category = id <= 8 and "physical" or "special" }
end
if not native then
require("src.battle.TypeChart").load(data)
end
local contexts = {
{ name = "normal-stab" },
{ name = "no-stab", userType = "FIRE" },
{ name = "burn", status = "BRN" },
{ name = "reflect", reflect = true },
{ name = "double-reflect", reflect = true, double = true },
{ name = "spread", spread = true },
{ name = "spread-reflect", spread = true, reflect = true, double = true },
{ name = "resistance", targetType = "STEEL" },
{ name = "immunity", targetType = "GHOST" },
{ name = "rain-fire", moveType = "FIRE", weather = "RAIN" },
{ name = "sun-fire", moveType = "FIRE", weather = "SUN" },
{ name = "rain-water", moveType = "WATER", weather = "RAIN" },
{ name = "sun-water", moveType = "WATER", weather = "SUN" },
{ name = "light-screen", moveType = "FIRE", lightScreen = true },
{ name = "double-light-screen", moveType = "FIRE", lightScreen = true, double = true },
{ name = "rain-solarbeam", moveType = "GRASS", weather = "RAIN", solar = true },
{ name = "sand-solarbeam", moveType = "GRASS", weather = "SAND", solar = true },
{ name = "hail-solarbeam", moveType = "GRASS", weather = "HAIL", solar = true },
}
local function battler(level, attack, defense, kind, status)
if native then
return {
mon = {
level = level,
hp = 6000,
maxHp = 6000,
attack = attack,
defense = defense,
spAtk = attack,
spDef = defense,
speed = 50,
ability = 0,
status = status,
},
type1 = ids[kind],
type2 = ids[kind],
ability = 0,
stages = { attack = 0, defense = 0, spAtk = 0, spDef = 0 },
}
end
return {
mon = { level = level, hp = 6000, status = status },
curStats = { attack = attack, defense = defense, spatk = attack, spdef = defense, special = attack },
curTypes = { kind },
stages = { attack = 0, defense = 0, spatk = 0, spdef = 0 },
}
end
local rows = {}
for _, ctx in ipairs(contexts) do
for _, level in ipairs({ 1, 5, 29, 30, 50, 100 }) do
for _, power in ipairs({ 2, 35, 120, 250 }) do
for _, stats in ipairs({ { 50, 50 }, { 255, 257 }, { 300, 50 }, { 500, 10 } }) do
for _, roll in ipairs({ 85, 100 }) do
local kind = ctx.moveType or "NORMAL"
local user = battler(level, stats[1], 50, ctx.userType or kind, ctx.status)
local target = battler(level, 50, stats[2], ctx.targetType or "NORMAL")
local result
if native then
result = D.calc(user, target, {
id = "PORTABLE_BASE",
numId = 33,
power = power,
type = ids[kind],
effect = ctx.solar and 151 or 0,
}, {
forceCrit = false,
forceRoll = roll,
weather = ctx.weather,
spread = ctx.spread,
reflect = ctx.reflect,
lightScreen = ctx.lightScreen,
doubleScreens = ctx.double,
})
else
result = D.compute(rules, user, target, {
id = "PORTABLE_BASE",
power = power,
type = kind,
effect = ctx.solar and "SOLARBEAM_EFFECT" or "NO_ADDITIONAL_EFFECT",
gen3Effect = ctx.solar and 151 or 0,
}, {
forceCrit = false,
data = data,
rng = function()
return roll
end,
weather = ctx.weather,
spread = ctx.spread,
screens = { reflect = ctx.reflect, lightScreen = ctx.lightScreen },
doublesScreens = ctx.double,
})
end
T.check(type(result) == "number" and result >= 0, "ordinary damage is nonnegative")
T.eq(user.mon.hp, 6000, "calculation cannot spend attacker HP")
T.eq(target.mon.hp, 6000, "calculation cannot spend target HP")
rows[#rows + 1] = {
context = ctx.name,
level = level,
power = power,
attack = stats[1],
defense = stats[2],
roll = roll,
damage = result,
}
end
end
end
end
end
return rows
end
return C
5 changes: 5 additions & 0 deletions tests/gba_damage_base/worker.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local root, backend, version = unpack(arg)
local rows = dofile(root .. "/tests/gba_damage_base/cases.lua").run(backend, version)
print("GBA_DAMAGE_BASE " .. require("src.link.Json").encode(rows))
require("tests.modkit").finish("ordinary GBA base damage")
Loading