From ec1a283b1b1d987879f46cddb5c9053436b1f32b Mon Sep 17 00:00:00 2001 From: hedara90 <90hedara@gmail.com> Date: Tue, 16 Dec 2025 19:19:34 +0100 Subject: [PATCH] Add SUB_HIT check to tests (#8413) Co-authored-by: Hedara --- docs/tutorials/how_to_testing_system.md | 14 +++ include/test/battle.h | 21 ++++ include/test_runner.h | 2 + src/battle_script_commands.c | 2 + test/battle/move_effect/substitute.c | 107 +++++++++++++++++++++ test/test_runner_battle.c | 121 ++++++++++++++++++++++++ 6 files changed, 267 insertions(+) diff --git a/docs/tutorials/how_to_testing_system.md b/docs/tutorials/how_to_testing_system.md index 53b56bd26b..c2b9912898 100644 --- a/docs/tutorials/how_to_testing_system.md +++ b/docs/tutorials/how_to_testing_system.md @@ -464,6 +464,20 @@ If the expected status icon is parametrized the corresponding `STATUS1` constant STATUS_ICON(player, status1); ``` +### `SUB_HIT` +`SUB_HIT(battler, captureDamage: | subBreak:)` +Causes the test to fail the test to fail if a Substitute for the specified battler doesn't take damage. +If `captureDamage` is used, the damage the substitute takes is written to the supplied pointer. +``` +u16 damage; +... +SUB_HIT(player, captureDamage: &damage); +``` +If `subBreak` is set to `TRUE`, the test will fail unless the substitute breaks. And if set to `FALSE`, the test will fail unless the substitute survives. +``` +SUB_HIT(player, subBreak: TRUE); +``` + ### `NOT` `NOT sceneCommand` Causes the test to fail if the `SCENE` command succeeds before the following command succeeds. diff --git a/include/test/battle.h b/include/test/battle.h index e747f818e9..198de63f3c 100644 --- a/include/test/battle.h +++ b/include/test/battle.h @@ -606,6 +606,7 @@ enum QUEUED_ABILITY_POPUP_EVENT, QUEUED_ANIMATION_EVENT, QUEUED_HP_EVENT, + QUEUED_SUB_HIT_EVENT, QUEUED_EXP_EVENT, QUEUED_MESSAGE_EVENT, QUEUED_STATUS_EVENT, @@ -635,6 +636,14 @@ struct QueuedHPEvent u32 address:28; }; +struct QueuedSubHitEvent +{ + u32 battlerId:3; + u32 checkBreak:1; + u32 breakSub:1; + u32 address:27; +}; + struct QueuedExpEvent { u32 battlerId:3; @@ -664,6 +673,7 @@ struct QueuedEvent struct QueuedAbilityEvent ability; struct QueuedAnimationEvent animation; struct QueuedHPEvent hp; + struct QueuedSubHitEvent subHit; struct QueuedExpEvent exp; struct QueuedMessageEvent message; struct QueuedStatusEvent status; @@ -1173,6 +1183,7 @@ void SendOut(u32 sourceLine, struct BattlePokemon *, u32 partyIndex); #define ABILITY_POPUP(battler, ...) QueueAbility(__LINE__, battler, (struct AbilityEventContext) { __VA_ARGS__ }) #define ANIMATION(type, id, ...) QueueAnimation(__LINE__, type, id, (struct AnimationEventContext) { __VA_ARGS__ }) #define HP_BAR(battler, ...) QueueHP(__LINE__, battler, (struct HPEventContext) { R_APPEND_TRUE(__VA_ARGS__) }) +#define SUB_HIT(battler, ...) QueueSubHit(__LINE__, battler, (struct SubHitEventContext) { R_APPEND_TRUE(__VA_ARGS__) }) #define EXPERIENCE_BAR(battler, ...) QueueExp(__LINE__, battler, (struct ExpEventContext) { R_APPEND_TRUE(__VA_ARGS__) }) // Static const is needed to make the modern compiler put the pattern variable in the .rodata section, instead of putting it on stack(which can break the game). #define MESSAGE(pattern) do {static const u8 msg[] = _(pattern); QueueMessage(__LINE__, msg);} while (0) @@ -1225,6 +1236,15 @@ struct HPEventContext bool8 explicitCaptureDamage; }; +struct SubHitEventContext +{ + u8 _; + bool8 subBreak; + bool8 explicitSubBreak; + u16 *captureDamage; + bool8 explicitCaptureDamage; +}; + struct ExpEventContext { u8 _; @@ -1253,6 +1273,7 @@ void CloseQueueGroup(u32 sourceLine); void QueueAbility(u32 sourceLine, struct BattlePokemon *battler, struct AbilityEventContext); void QueueAnimation(u32 sourceLine, u32 type, u32 id, struct AnimationEventContext); void QueueHP(u32 sourceLine, struct BattlePokemon *battler, struct HPEventContext); +void QueueSubHit(u32 sourceLine, struct BattlePokemon *battler, struct SubHitEventContext); void QueueExp(u32 sourceLine, struct BattlePokemon *battler, struct ExpEventContext); void QueueMessage(u32 sourceLine, const u8 *pattern); void QueueStatus(u32 sourceLine, struct BattlePokemon *battler, struct StatusEventContext); diff --git a/include/test_runner.h b/include/test_runner.h index b1d90889fb..fa97da9881 100644 --- a/include/test_runner.h +++ b/include/test_runner.h @@ -16,6 +16,7 @@ enum Gimmick; void TestRunner_Battle_RecordAbilityPopUp(u32 battlerId, enum Ability ability); void TestRunner_Battle_RecordAnimation(u32 animType, u32 animId); void TestRunner_Battle_RecordHP(u32 battlerId, u32 oldHP, u32 newHP); +void TestRunner_Battle_RecordSubHit(u32 battlerId, u32 damage, bool32 broke); void TestRunner_Battle_RecordExp(u32 battlerId, u32 oldExp, u32 newExp); void TestRunner_Battle_RecordMessage(const u8 *message); void TestRunner_Battle_RecordStatus1(u32 battlerId, u32 status1); @@ -39,6 +40,7 @@ u32 TestRunner_Battle_GetForcedEnvironment(void); #define TestRunner_Battle_RecordAbilityPopUp(...) (void)0 #define TestRunner_Battle_RecordAnimation(...) (void)0 #define TestRunner_Battle_RecordHP(...) (void)0 +#define TestRunner_Battle_RecordSubHit(...) (void)0 #define TestRunner_Battle_RecordExp(...) (void)0 #define TestRunner_Battle_RecordMessage(...) (void)0 #define TestRunner_Battle_RecordStatus1(...) (void)0 diff --git a/src/battle_script_commands.c b/src/battle_script_commands.c index 1ab165ce8f..92c4bff67c 100644 --- a/src/battle_script_commands.c +++ b/src/battle_script_commands.c @@ -2365,10 +2365,12 @@ static void MoveDamageDataHpUpdate(u32 battler, u32 scriptBattler, const u8 *nex { if (gDisableStructs[battler].substituteHP >= gBattleStruct->moveDamage[battler]) { + TestRunner_Battle_RecordSubHit(battler, gBattleStruct->moveDamage[battler], FALSE); gDisableStructs[battler].substituteHP -= gBattleStruct->moveDamage[battler]; } else { + TestRunner_Battle_RecordSubHit(battler, gDisableStructs[battler].substituteHP, TRUE); gBattleStruct->moveDamage[battler] = gDisableStructs[battler].substituteHP; gDisableStructs[battler].substituteHP = 0; } diff --git a/test/battle/move_effect/substitute.c b/test/battle/move_effect/substitute.c index e94767b660..ce174ec025 100644 --- a/test/battle/move_effect/substitute.c +++ b/test/battle/move_effect/substitute.c @@ -69,4 +69,111 @@ SINGLE_BATTLE_TEST("Substitute's HP cost doesn't trigger effects that trigger on } } +SINGLE_BATTLE_TEST("Substitute hits are detected by SUB_HIT") +{ + GIVEN { + PLAYER(SPECIES_WOBBUFFET); + OPPONENT(SPECIES_WOBBUFFET); + } WHEN { + TURN { MOVE(player, MOVE_SUBSTITUTE); MOVE(opponent, MOVE_SCRATCH); } + } SCENE { + SUB_HIT(player); + } +} + +SINGLE_BATTLE_TEST("Substitute hits are detected by SUB_HIT, break TRUE") +{ + GIVEN { + PLAYER(SPECIES_WOBBUFFET) { Level(1); } + OPPONENT(SPECIES_WOBBUFFET); + } WHEN { + TURN { MOVE(player, MOVE_SUBSTITUTE); MOVE(opponent, MOVE_SCRATCH); } + } SCENE { + SUB_HIT(player, subBreak: TRUE); + } +} + +SINGLE_BATTLE_TEST("Substitute hits are detected by SUB_HIT, break FALSE") +{ + GIVEN { + PLAYER(SPECIES_WOBBUFFET) { Level(100); } + OPPONENT(SPECIES_WOBBUFFET); + } WHEN { + TURN { MOVE(player, MOVE_SUBSTITUTE); MOVE(opponent, MOVE_SCRATCH); } + } SCENE { + SUB_HIT(player, subBreak: FALSE); + } +} + +SINGLE_BATTLE_TEST("Substitute hits are detected by SUB_HIT, records damage") +{ + u16 damage; + GIVEN { + PLAYER(SPECIES_WOBBUFFET); + OPPONENT(SPECIES_WOBBUFFET); + } WHEN { + TURN { MOVE(player, MOVE_SUBSTITUTE); MOVE(opponent, MOVE_SCRATCH); } + } SCENE { + SUB_HIT(player, captureDamage: &damage); + } THEN { + EXPECT_GT(damage, 0); + } +} + +SINGLE_BATTLE_TEST("Substitute hits are detected by SUB_HIT, records damage, break FALSE") +{ + u16 damage; + GIVEN { + PLAYER(SPECIES_WOBBUFFET); + OPPONENT(SPECIES_WOBBUFFET); + } WHEN { + TURN { MOVE(player, MOVE_SUBSTITUTE); MOVE(opponent, MOVE_SCRATCH); } + } SCENE { + SUB_HIT(player, captureDamage: &damage, subBreak: FALSE); + } THEN { + EXPECT_GT(damage, 0); + } +} + +SINGLE_BATTLE_TEST("Substitute hits are detected by SUB_HIT, records damage, break TRUE") +{ + u16 damage; + GIVEN { + PLAYER(SPECIES_WOBBUFFET) { Level(1); } + OPPONENT(SPECIES_WOBBUFFET); + } WHEN { + TURN { MOVE(player, MOVE_SUBSTITUTE); MOVE(opponent, MOVE_SCRATCH); } + } SCENE { + SUB_HIT(player, captureDamage: &damage, subBreak: TRUE); + } THEN { + EXPECT_GT(damage, 0); + } +} + +SINGLE_BATTLE_TEST("Substitute hits are detected by SUB_HIT, break TRUE, failing") +{ + KNOWN_FAILING; // For testing purposes + GIVEN { + PLAYER(SPECIES_WOBBUFFET) { Level(100); } + OPPONENT(SPECIES_WOBBUFFET); + } WHEN { + TURN { MOVE(player, MOVE_SUBSTITUTE); MOVE(opponent, MOVE_SCRATCH); } + } SCENE { + SUB_HIT(player, subBreak: TRUE); + } +} + +SINGLE_BATTLE_TEST("Substitute hits are detected by SUB_HIT, break FALSE, failing") +{ + KNOWN_FAILING; // For testing purposes + GIVEN { + PLAYER(SPECIES_WOBBUFFET) { Level(1); } + OPPONENT(SPECIES_WOBBUFFET); + } WHEN { + TURN { MOVE(player, MOVE_SUBSTITUTE); MOVE(opponent, MOVE_SCRATCH); } + } SCENE { + SUB_HIT(player, subBreak: FALSE); + } +} + TO_DO_BATTLE_TEST("Baton Pass passes Substitutes"); diff --git a/test/test_runner_battle.c b/test/test_runner_battle.c index 113ac2f4f2..9bc28363d4 100644 --- a/test/test_runner_battle.c +++ b/test/test_runner_battle.c @@ -23,6 +23,7 @@ #undef TestRunner_Battle_RecordAbilityPopUp #undef TestRunner_Battle_RecordAnimation #undef TestRunner_Battle_RecordHP +#undef TestRunner_Battle_RecordSubHit #undef TestRunner_Battle_RecordMessage #undef TestRunner_Battle_RecordStatus1 #undef TestRunner_Battle_AfterLastTurn @@ -946,6 +947,85 @@ void TestRunner_Battle_RecordHP(u32 battlerId, u32 oldHP, u32 newHP) } } +static s32 TrySubHit(s32 i, s32 n, u32 battlerId, u32 damage, bool32 broke) +{ + struct QueuedSubHitEvent *event; + s32 iMax = i + n; + for (; i < iMax; i++) + { + if (DATA.queuedEvents[i].type != QUEUED_SUB_HIT_EVENT) + continue; + + event = &DATA.queuedEvents[i].as.subHit; + + if (event->battlerId == battlerId) + { + if (event->checkBreak) + { + if (event->breakSub && !broke) + return -1; + else if (!event->breakSub && broke) + return -1; + } + + if (event->address <= 0xFFFF) + { + event->address = damage; + return i; + } + else + { + *(u16 *)(u32)(event->address) = damage; + return i; + } + } + } + return -1; +} + +void TestRunner_Battle_RecordSubHit(u32 battlerId, u32 damage, bool32 broke) +{ + s32 queuedEvent; + s32 match; + struct QueuedEvent *event; + + if (DATA.trial.queuedEvent == DATA.queuedEventsCount) + return; + + event = &DATA.queuedEvents[DATA.trial.queuedEvent]; + switch (event->groupType) + { + case QUEUE_GROUP_NONE: + case QUEUE_GROUP_ONE_OF: + if (TrySubHit(DATA.trial.queuedEvent, event->groupSize, battlerId, damage, broke) != -1) + DATA.trial.queuedEvent += event->groupSize; + break; + case QUEUE_GROUP_NONE_OF: + queuedEvent = DATA.trial.queuedEvent; + do + { + if ((match = TrySubHit(queuedEvent, event->groupSize, battlerId, damage, broke)) != -1) + { + const char *filename = gTestRunnerState.test->filename; + u32 line = SourceLine(DATA.queuedEvents[match].sourceLineOffset); + Test_ExitWithResult(TEST_RESULT_FAIL, line, ":L%s:%d: Matched SUB_HIT", filename, line); + } + + queuedEvent += event->groupSize; + if (queuedEvent == DATA.queuedEventsCount) + break; + + event = &DATA.queuedEvents[queuedEvent]; + if (event->groupType == QUEUE_GROUP_NONE_OF) + continue; + + if (TrySubHit(queuedEvent, event->groupSize, battlerId, damage, broke) != -1) + DATA.trial.queuedEvent = queuedEvent + event->groupSize; + } while (FALSE); + break; + } +} + static const char *const sBattleActionNames[] = { [B_ACTION_USE_MOVE] = "MOVE", @@ -1505,6 +1585,7 @@ static const char *const sEventTypeMacros[] = [QUEUED_ABILITY_POPUP_EVENT] = "ABILITY_POPUP", [QUEUED_ANIMATION_EVENT] = "ANIMATION", [QUEUED_HP_EVENT] = "HP_BAR", + [QUEUED_SUB_HIT_EVENT] = "SUB_HIT", [QUEUED_EXP_EVENT] = "EXPERIENCE_BAR", [QUEUED_MESSAGE_EVENT] = "MESSAGE", [QUEUED_STATUS_EVENT] = "STATUS_ICON", @@ -2995,6 +3076,46 @@ void QueueHP(u32 sourceLine, struct BattlePokemon *battler, struct HPEventContex }; } +void QueueSubHit(u32 sourceLine, struct BattlePokemon *battler, struct SubHitEventContext ctx) +{ + s32 battlerId = battler - gBattleMons; + bool32 breakSub = FALSE; + bool32 checkBreak = FALSE; + uintptr_t address; + + INVALID_IF(!STATE->runScene, "SUB_HIT outside of SCENE"); + if (DATA.queuedEventsCount == MAX_QUEUED_EVENTS) + Test_ExitWithResult(TEST_RESULT_ERROR, sourceLine, ":L%s:%d: SUB_HIT exceeds MAX_QUEUED_EVENTS", gTestRunnerState.test->filename, sourceLine); + + address = 0; + if (ctx.explicitCaptureDamage) + { + INVALID_IF(ctx.captureDamage == NULL, "captureDamage is NULL"); + *ctx.captureDamage = 0; + address = (uintptr_t)ctx.captureDamage; + } + + if (ctx.explicitSubBreak) + { + checkBreak = TRUE; + if (ctx.subBreak) + breakSub = TRUE; + } + + DATA.queuedEvents[DATA.queuedEventsCount++] = (struct QueuedEvent) { + .type = QUEUED_SUB_HIT_EVENT, + .sourceLineOffset = SourceLineOffset(sourceLine), + .groupType = QUEUE_GROUP_NONE, + .groupSize = 1, + .as = { .subHit = { + .battlerId = battlerId, + .checkBreak = checkBreak, + .breakSub = breakSub, + .address = address, + }}, + }; +} + void QueueExp(u32 sourceLine, struct BattlePokemon *battler, struct ExpEventContext ctx) { s32 battlerId = battler - gBattleMons;