From a9a3cbf2b4973865378649a980cbd3c5143c672b Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:40:12 +0200 Subject: [PATCH 1/2] Use native GBA base-damage rounding --- .../workflows/gba-damage-base-contract.yml | 34 +++++ docs/gba-base-damage.md | 50 +++++++ src/battle/Damage.lua | 42 +++++- src/battle/rulesets/gen3_emerald.lua | 3 + tests/engine/gba_damage_base.lua | 35 +++++ tests/gba_damage_base/cases.lua | 139 ++++++++++++++++++ tests/gba_damage_base/worker.lua | 5 + tools/gba-damage-base-contract-check/check.py | 56 +++++++ 8 files changed, 359 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/gba-damage-base-contract.yml create mode 100644 docs/gba-base-damage.md create mode 100644 tests/engine/gba_damage_base.lua create mode 100644 tests/gba_damage_base/cases.lua create mode 100644 tests/gba_damage_base/worker.lua create mode 100644 tools/gba-damage-base-contract-check/check.py diff --git a/.github/workflows/gba-damage-base-contract.yml b/.github/workflows/gba-damage-base-contract.yml new file mode 100644 index 000000000..75fe6e0f1 --- /dev/null +++ b/.github/workflows/gba-damage-base-contract.yml @@ -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 diff --git a/docs/gba-base-damage.md b/docs/gba-base-damage.md new file mode 100644 index 000000000..170ae5b9f --- /dev/null +++ b/docs/gba-base-damage.md @@ -0,0 +1,50 @@ +# 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. diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua index 2664e4b17..378efa722 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -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`. @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/src/battle/rulesets/gen3_emerald.lua b/src/battle/rulesets/gen3_emerald.lua index 3a7fc6011..360ab76d8 100644 --- a/src/battle/rulesets/gen3_emerald.lua +++ b/src/battle/rulesets/gen3_emerald.lua @@ -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% diff --git a/tests/engine/gba_damage_base.lua b/tests/engine/gba_damage_base.lua new file mode 100644 index 000000000..e6e0a196d --- /dev/null +++ b/tests/engine/gba_damage_base.lua @@ -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") diff --git a/tests/gba_damage_base/cases.lua b/tests/gba_damage_base/cases.lua new file mode 100644 index 000000000..e2fdf1b9b --- /dev/null +++ b/tests/gba_damage_base/cases.lua @@ -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 diff --git a/tests/gba_damage_base/worker.lua b/tests/gba_damage_base/worker.lua new file mode 100644 index 000000000..7a2fdc5d0 --- /dev/null +++ b/tests/gba_damage_base/worker.lua @@ -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") diff --git a/tools/gba-damage-base-contract-check/check.py b/tools/gba-damage-base-contract-check/check.py new file mode 100644 index 000000000..107efa6c0 --- /dev/null +++ b/tools/gba-damage-base-contract-check/check.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Compare ordinary GBA damage through both actual engine calculators, without ROMs.""" +import argparse +import json +import os +from pathlib import Path +import subprocess + +REFERENCE = "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--gen1", type=Path, required=True) + parser.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + parser.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + parser.add_argument("--report", type=Path) + args = parser.parse_args() + source, host = args.gen1.resolve(), args.gen2.resolve() + if git(source, "rev-parse", "HEAD") != REFERENCE or git(source, "status", "--porcelain"): + parser.error("reference must be clean at " + REFERENCE) + runs, differences = [], [] + for version in ["firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", source), ("gen2", host)]: + run = subprocess.run([args.lua, str(host / "tests/gba_damage_base/worker.lua"), str(host), + backend, version], cwd=engine, capture_output=True, text=True, timeout=30) + prefix = "GBA_DAMAGE_BASE " + lines = [line.removeprefix(prefix) for line in run.stdout.splitlines() if line.startswith(prefix)] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + value = json.loads(lines[0]) + observations.append(value) + runs.append({"backend": backend, "version": version, "observations": value}) + if len(observations[0]) != len(observations[1]): + differences.append({"version": version, "error": "observation count"}) + for index, (left, right) in enumerate(zip(*observations)): + if left != right: + differences.append({"version": version, "row": index, "reference": left, "host": right}) + print("PASS" if observations[0] == observations[1] else "FAIL", version) + assert not git(source, "status", "--porcelain"), "reference changed" + report = {"reference_ref": REFERENCE, "host_ref": git(host, "rev-parse", "HEAD"), + "host_dirty": bool(git(host, "status", "--porcelain")), "runs": runs, + "differences": differences} + if args.report: + args.report.write_text(json.dumps(report, indent=2) + "\n") + if differences: + raise SystemExit(f"{len(differences)} differing ordinary GBA damage observations") + + +if __name__ == "__main__": + main() From a7944332a5136d7814279b6fe31c6e14020480c1 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:44:12 +0200 Subject: [PATCH 2/2] Record GBA base-damage contract validation --- docs/gba-base-damage.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/gba-base-damage.md b/docs/gba-base-damage.md index 170ae5b9f..710074c0e 100644 --- a/docs/gba-base-damage.md +++ b/docs/gba-base-damage.md @@ -48,3 +48,23 @@ 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.