RNG for Metronome, multi-hit moves, and Loaded Dice (#3159)

This commit is contained in:
ghoulslash 2023-07-23 08:15:14 -04:00 committed by GitHub
commit b5431898c2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 222 additions and 158 deletions

View File

@ -32,6 +32,10 @@ void SeedRng2(u16 seed);
* RandomUniform(tag, lo, hi) returns a number from lo to hi inclusive
* with uniform probability.
*
* RandomUniformExcept(tag, lo, hi, reject) returns a number from lo to
* hi inclusive with uniform probability, excluding those for which
* reject returns TRUE.
*
* RandomElement(tag, array) returns an element in array with uniform
* probability. The array must be known at compile-time (e.g. a global
* const array).
@ -56,8 +60,11 @@ enum RandomTag
RNG_FLAME_BODY,
RNG_FORCE_RANDOM_SWITCH,
RNG_FROZEN,
RNG_HITS,
RNG_HOLD_EFFECT_FLINCH,
RNG_INFATUATION,
RNG_LOADED_DICE,
RNG_METRONOME,
RNG_PARALYSIS,
RNG_POISON_POINT,
RNG_RAMPAGE_TURNS,
@ -103,10 +110,12 @@ enum RandomTag
})
u32 RandomUniform(enum RandomTag, u32 lo, u32 hi);
u32 RandomUniformExcept(enum RandomTag, u32 lo, u32 hi, bool32 (*reject)(u32));
u32 RandomWeightedArray(enum RandomTag, u32 sum, u32 n, const u8 *weights);
const void *RandomElementArray(enum RandomTag, const void *array, size_t size, size_t count);
u32 RandomUniformDefault(enum RandomTag, u32 lo, u32 hi);
u32 RandomUniformExceptDefault(enum RandomTag, u32 lo, u32 hi, bool32 (*reject)(u32));
u32 RandomWeightedArrayDefault(enum RandomTag, u32 sum, u32 n, const u8 *weights);
const void *RandomElementArrayDefault(enum RandomTag, const void *array, size_t size, size_t count);

View File

@ -12206,29 +12206,13 @@ static void Cmd_setmultihitcounter(void)
}
else
{
// WARNING: These seem to be unused, see SetRandomMultiHitCounter.
#if B_MULTI_HIT_CHANCE >= GEN_5
// Based on Gen 5's odds
// 35% for 2 hits
// 35% for 3 hits
// 15% for 4 hits
// 15% for 5 hits
gMultiHitCounter = Random() % 100;
if (gMultiHitCounter < 35)
gMultiHitCounter = 2;
else if (gMultiHitCounter < 35 + 35)
gMultiHitCounter = 3;
else if (gMultiHitCounter < 35 + 35 + 15)
gMultiHitCounter = 4;
else
gMultiHitCounter = 5;
// 35%: 2 hits, 35%: 3 hits, 15% 4 hits, 15% 5 hits.
gMultiHitCounter = RandomWeighted(RNG_HITS, 0, 0, 7, 7, 3, 3);
#else
// 2 and 3 hits: 37.5%
// 4 and 5 hits: 12.5%
gMultiHitCounter = Random() % 4;
if (gMultiHitCounter > 1)
gMultiHitCounter = (Random() % 4) + 2;
else
gMultiHitCounter += 2;
// 37.5%: 2 hits, 37.5%: 3 hits, 12.5% 4 hits, 12.5% 5 hits.
gMultiHitCounter = RandomWeighted(RNG_HITS, 0, 0, 3, 3, 1, 1);
#endif
}
}
@ -12977,41 +12961,37 @@ static void Cmd_mimicattackcopy(void)
}
}
static bool32 InvalidMetronomeMove(u32 move)
{
return gBattleMoves[move].effect == EFFECT_PLACEHOLDER
|| sForbiddenMoves[move] & FORBIDDEN_METRONOME;
}
static void Cmd_metronome(void)
{
CMD_ARGS();
#if B_METRONOME_MOVES >= GEN_9
u16 moveCount = MOVES_COUNT_GEN9;
u32 moveCount = MOVES_COUNT_GEN9;
#elif B_METRONOME_MOVES >= GEN_8
u16 moveCount = MOVES_COUNT_GEN8;
u32 moveCount = MOVES_COUNT_GEN8;
#elif B_METRONOME_MOVES >= GEN_7
u16 moveCount = MOVES_COUNT_GEN7;
u32 moveCount = MOVES_COUNT_GEN7;
#elif B_METRONOME_MOVES >= GEN_6
u16 moveCount = MOVES_COUNT_GEN6;
u32 moveCount = MOVES_COUNT_GEN6;
#elif B_METRONOME_MOVES >= GEN_5
u16 moveCount = MOVES_COUNT_GEN5;
u32 moveCount = MOVES_COUNT_GEN5;
#elif B_METRONOME_MOVES >= GEN_4
u16 moveCount = MOVES_COUNT_GEN4;
u32 moveCount = MOVES_COUNT_GEN4;
#elif B_METRONOME_MOVES >= GEN_3
u16 moveCount = MOVES_COUNT_GEN3;
u32 moveCount = MOVES_COUNT_GEN3;
#endif
while (TRUE)
{
gCurrentMove = (Random() % (moveCount - 1)) + 1;
if (gBattleMoves[gCurrentMove].effect == EFFECT_PLACEHOLDER)
continue;
if (!(sForbiddenMoves[gCurrentMove] & FORBIDDEN_METRONOME))
{
gCurrentMove = RandomUniformExcept(RNG_METRONOME, 1, moveCount - 1, InvalidMetronomeMove);
gHitMarker &= ~HITMARKER_ATTACKSTRING_PRINTED;
SetAtkCancellerForCalledMove();
gBattlescriptCurrInstr = gBattleScriptsForMoveEffects[gBattleMoves[gCurrentMove].effect];
gBattlerTarget = GetMoveTarget(gCurrentMove, NO_TARGET_OVERRIDE);
return;
}
}
}
static void Cmd_dmgtolevel(void)

View File

@ -10899,35 +10899,19 @@ bool32 CanTargetBattler(u8 battlerAtk, u8 battlerDef, u16 move)
static void SetRandomMultiHitCounter()
{
#if (B_MULTI_HIT_CHANCE >= GEN_5)
// Based on Gen 5's odds
// 35% for 2 hits
// 35% for 3 hits
// 15% for 4 hits
// 15% for 5 hits
gMultiHitCounter = Random() % 100;
if (gMultiHitCounter < 35)
gMultiHitCounter = 2;
else if (gMultiHitCounter < 35 + 35)
gMultiHitCounter = 3;
else if (gMultiHitCounter < 35 + 35 + 15)
gMultiHitCounter = 4;
else
gMultiHitCounter = 5;
#else
// 2 and 3 hits: 37.5%
// 4 and 5 hits: 12.5%
gMultiHitCounter = Random() % 4;
if (gMultiHitCounter > 1)
gMultiHitCounter = (Random() % 4) + 2;
else
gMultiHitCounter += 2;
#endif
if (gMultiHitCounter < 4 && GetBattlerHoldEffect(gBattlerAttacker, TRUE) == HOLD_EFFECT_LOADED_DICE)
if (GetBattlerHoldEffect(gBattlerAttacker, TRUE) == HOLD_EFFECT_LOADED_DICE)
{
// If roll 4 or 5 Loaded Dice doesn't do anything. Otherwise it rolls the number of hits as 5 minus a random integer from 0 to 1 inclusive.
gMultiHitCounter = 5 - (Random() & 1);
gMultiHitCounter = RandomUniform(RNG_LOADED_DICE, 4, 5);
}
else
{
#if B_MULTI_HIT_CHANCE >= GEN_5
// 35%: 2 hits, 35%: 3 hits, 15% 4 hits, 15% 5 hits.
gMultiHitCounter = RandomWeighted(RNG_HITS, 0, 0, 7, 7, 3, 3);
#else
// 37.5%: 2 hits, 37.5%: 3 hits, 12.5% 4 hits, 12.5% 5 hits.
gMultiHitCounter = RandomWeighted(RNG_HITS, 0, 0, 3, 3, 1, 1);
#endif
}
}

View File

@ -35,6 +35,9 @@ u16 Random2(void)
__attribute__((weak, alias("RandomUniformDefault")))
u32 RandomUniform(enum RandomTag tag, u32 lo, u32 hi);
__attribute__((weak, alias("RandomUniformExceptDefault")))
u32 RandomUniformExcept(enum RandomTag, u32 lo, u32 hi, bool32 (*reject)(u32));
__attribute__((weak, alias("RandomWeightedArrayDefault")))
u32 RandomWeightedArray(enum RandomTag tag, u32 sum, u32 n, const u8 *weights);
@ -46,6 +49,16 @@ u32 RandomUniformDefault(enum RandomTag tag, u32 lo, u32 hi)
return lo + (((hi - lo + 1) * Random()) >> 16);
}
u32 RandomUniformExceptDefault(enum RandomTag tag, u32 lo, u32 hi, bool32 (*reject)(u32))
{
while (TRUE)
{
u32 n = RandomUniformDefault(tag, lo, hi);
if (!reject(n))
return n;
}
}
u32 RandomWeightedArrayDefault(enum RandomTag tag, u32 sum, u32 n, const u8 *weights)
{
s32 i, targetSum;

View File

@ -6,19 +6,13 @@ ASSUMPTIONS
ASSUME(gBattleMoves[MOVE_METRONOME].effect == EFFECT_METRONOME);
}
// To do: Turn the seeds to work with WITH_RNG for Metronome.
#define RNG_METRONOME_SCRATCH 0x118
#define RNG_METRONOME_PSN_POWDER 0x119
#define RNG_METRONOME_ROCK_BLAST 0x1F5
SINGLE_BATTLE_TEST("Metronome picks a random move")
{
GIVEN {
RNGSeed(RNG_METRONOME_SCRATCH);
PLAYER(SPECIES_WOBBUFFET);
OPPONENT(SPECIES_WOBBUFFET);
} WHEN {
TURN { MOVE(player, MOVE_METRONOME); }
TURN { MOVE(player, MOVE_METRONOME, WITH_RNG(RNG_METRONOME, MOVE_SCRATCH)); }
} SCENE {
MESSAGE("Wobbuffet used Metronome!");
ANIMATION(ANIM_TYPE_MOVE, MOVE_METRONOME, player);
@ -34,11 +28,10 @@ SINGLE_BATTLE_TEST("Metronome's called powder move fails against Grass Types")
ASSUME(gBattleMoves[MOVE_POISON_POWDER].flags & FLAG_POWDER);
ASSUME(gSpeciesInfo[SPECIES_TANGELA].types[0] == TYPE_GRASS);
ASSUME(gBattleMoves[MOVE_POISON_POWDER].effect == EFFECT_POISON);
RNGSeed(RNG_METRONOME_PSN_POWDER);
PLAYER(SPECIES_WOBBUFFET) {Speed(5);}
OPPONENT(SPECIES_TANGELA) {Speed(2);}
PLAYER(SPECIES_WOBBUFFET);
OPPONENT(SPECIES_TANGELA);
} WHEN {
TURN { MOVE(player, MOVE_METRONOME); }
TURN { MOVE(player, MOVE_METRONOME, WITH_RNG(RNG_METRONOME, MOVE_POISON_POWDER)); }
} SCENE {
MESSAGE("Wobbuffet used Metronome!");
ANIMATION(ANIM_TYPE_MOVE, MOVE_METRONOME, player);
@ -53,17 +46,16 @@ SINGLE_BATTLE_TEST("Metronome's called multi-hit move hits multiple times")
{
GIVEN {
ASSUME(gBattleMoves[MOVE_ROCK_BLAST].effect == EFFECT_MULTI_HIT);
RNGSeed(RNG_METRONOME_ROCK_BLAST);
PLAYER(SPECIES_WOBBUFFET);
OPPONENT(SPECIES_WOBBUFFET);
} WHEN {
TURN { MOVE(player, MOVE_METRONOME); }
TURN { MOVE(player, MOVE_METRONOME, WITH_RNG(RNG_METRONOME, MOVE_ROCK_BLAST)); }
} SCENE {
MESSAGE("Wobbuffet used Metronome!");
ANIMATION(ANIM_TYPE_MOVE, MOVE_METRONOME, player);
MESSAGE("Wobbuffet used Rock Blast!");
ANIMATION(ANIM_TYPE_MOVE, MOVE_ROCK_BLAST, player);
HP_BAR(opponent);
MESSAGE("Hit 4 time(s)!");
MESSAGE("Hit 5 time(s)!");
}
}

View File

@ -9,8 +9,8 @@ ASSUMPTIONS
SINGLE_BATTLE_TEST("Mirror Move copies the last used move by the target")
{
GIVEN {
PLAYER(SPECIES_WOBBUFFET) {Speed(2);}
OPPONENT(SPECIES_WOBBUFFET) {Speed(5);}
PLAYER(SPECIES_WOBBUFFET);
OPPONENT(SPECIES_WOBBUFFET);
} WHEN {
TURN { MOVE(opponent, MOVE_TACKLE); MOVE(player, MOVE_MIRROR_MOVE); }
} SCENE {
@ -26,10 +26,10 @@ SINGLE_BATTLE_TEST("Mirror Move copies the last used move by the target")
SINGLE_BATTLE_TEST("Mirror Move fails if no move was used before")
{
GIVEN {
PLAYER(SPECIES_WOBBUFFET) {Speed(5);}
OPPONENT(SPECIES_WOBBUFFET) {Speed(2);}
PLAYER(SPECIES_WOBBUFFET);
OPPONENT(SPECIES_WOBBUFFET);
} WHEN {
TURN { MOVE(opponent, MOVE_TACKLE); MOVE(player, MOVE_MIRROR_MOVE); }
TURN { MOVE(player, MOVE_MIRROR_MOVE); MOVE(opponent, MOVE_TACKLE); }
} SCENE {
MESSAGE("Wobbuffet used Mirror Move!");
MESSAGE("The Mirror Move failed!");
@ -44,8 +44,8 @@ SINGLE_BATTLE_TEST("Mirror Move's called powder move fails against Grass Types")
ASSUME(gBattleMoves[MOVE_STUN_SPORE].flags & FLAG_POWDER);
ASSUME(gSpeciesInfo[SPECIES_ODDISH].types[0] == TYPE_GRASS);
ASSUME(gBattleMoves[MOVE_STUN_SPORE].effect == EFFECT_PARALYZE);
PLAYER(SPECIES_ODDISH) {Speed(5);}
OPPONENT(SPECIES_WOBBUFFET) {Speed(2);}
PLAYER(SPECIES_ODDISH);
OPPONENT(SPECIES_WOBBUFFET);
} WHEN {
TURN { MOVE(player, MOVE_STUN_SPORE); MOVE(opponent, MOVE_MIRROR_MOVE); }
} SCENE {
@ -59,19 +59,18 @@ SINGLE_BATTLE_TEST("Mirror Move's called powder move fails against Grass Types")
}
}
// It hits first 2 times, then 5 times with the default rng seed.
SINGLE_BATTLE_TEST("Mirror Move's called multi-hit move hits multiple times")
{
GIVEN {
ASSUME(gBattleMoves[MOVE_BULLET_SEED].effect == EFFECT_MULTI_HIT);
PLAYER(SPECIES_WOBBUFFET) {Speed(5);}
OPPONENT(SPECIES_WOBBUFFET) {Speed(2);}
PLAYER(SPECIES_WOBBUFFET);
OPPONENT(SPECIES_WOBBUFFET);
} WHEN {
TURN { MOVE(player, MOVE_BULLET_SEED); MOVE(opponent, MOVE_MIRROR_MOVE); }
} SCENE {
ANIMATION(ANIM_TYPE_MOVE, MOVE_BULLET_SEED, player);
HP_BAR(opponent);
MESSAGE("Hit 2 time(s)!");
MESSAGE("Hit 5 time(s)!");
MESSAGE("Foe Wobbuffet used Mirror Move!");
MESSAGE("Foe Wobbuffet used Bullet Seed!");
ANIMATION(ANIM_TYPE_MOVE, MOVE_BULLET_SEED, opponent);

View File

@ -16,6 +16,25 @@ TEST("RandomUniform generates lo..hi")
}
}
static bool32 InvalidEven(u32 n)
{
return n % 2 == 0;
}
TEST("RandomUniformExcept generates lo..hi")
{
u32 lo, hi, i;
PARAMETRIZE { lo = 0; hi = 1; }
PARAMETRIZE { lo = 0; hi = 2; }
PARAMETRIZE { lo = 0; hi = 3; }
PARAMETRIZE { lo = 2; hi = 4; }
for (i = 0; i < 1024; i++)
{
u32 r = RandomUniformExceptDefault(RNG_NONE, lo, hi, InvalidEven);
EXPECT(lo <= r && r <= hi && r % 2 != 0);
}
}
TEST("RandomWeighted generates 0..n-1")
{
u32 n, sum, i;
@ -65,6 +84,29 @@ TEST("RandomUniform generates uniform distribution")
EXPECT_LT(error, UQ_4_12(0.025));
}
TEST("RandomUniformExcept generates uniform distribution")
{
u32 i, error;
u16 distribution[4];
memset(distribution, 0, sizeof(distribution));
for (i = 0; i < 4096; i++)
{
u32 r = RandomUniformExceptDefault(RNG_NONE, 0, ARRAY_COUNT(distribution) - 1, InvalidEven);
EXPECT(0 <= r && r < ARRAY_COUNT(distribution));
distribution[r]++;
}
error = 0;
for (i = 0; i < ARRAY_COUNT(distribution); i++)
{
if (i % 2 != 0)
error += abs(UQ_4_12(0.5) - distribution[i]);
}
EXPECT_LT(error, UQ_4_12(0.05));
}
TEST("RandomWeighted generates distribution in proportion to the weights")
{
u32 i, sum, error;

View File

@ -603,8 +603,9 @@ struct BattleTestRunnerState
u8 parameters;
u8 runParameter;
u16 rngTag;
u8 trials;
u8 runTrial;
u16 rngTrialOffset;
u16 trials;
u16 runTrial;
u16 expectedRatio;
u16 observedRatio;
u16 trialRatio;

View File

@ -307,17 +307,13 @@ static void BattleTest_Run(void *data)
u32 RandomUniform(enum RandomTag tag, u32 lo, u32 hi)
{
const struct BattlerTurn *turn = NULL;
u32 default_ = hi;
if (gCurrentTurnActionNumber < gBattlersCount)
{
u32 battlerId = gBattlerByTurnOrder[gCurrentTurnActionNumber];
turn = &DATA.battleRecordTurns[gBattleResults.battleTurnCounter][battlerId];
}
if (turn && turn->rng.tag == tag)
{
default_ = turn->rng.value;
return turn->rng.value;
}
if (tag == STATE->rngTag)
@ -332,53 +328,76 @@ u32 RandomUniform(enum RandomTag tag, u32 lo, u32 hi)
{
Test_ExitWithResult(TEST_RESULT_ERROR, "RandomUniform called with inconsistent trials %d and %d", STATE->trials, n);
}
STATE->trialRatio = Q_4_12(1) / n;
STATE->trialRatio = Q_4_12(1) / STATE->trials;
return STATE->runTrial + lo;
}
return hi;
}
u32 RandomUniformExcept(enum RandomTag tag, u32 lo, u32 hi, bool32 (*reject)(u32))
{
const struct BattlerTurn *turn = NULL;
u32 default_;
if (gCurrentTurnActionNumber < gBattlersCount)
{
u32 battlerId = gBattlerByTurnOrder[gCurrentTurnActionNumber];
turn = &DATA.battleRecordTurns[gBattleResults.battleTurnCounter][battlerId];
if (turn && turn->rng.tag == tag)
{
if (reject(turn->rng.value))
Test_ExitWithResult(TEST_RESULT_INVALID, "WITH_RNG specified a rejected value (%d)", turn->rng.value);
return turn->rng.value;
}
}
if (tag == STATE->rngTag)
{
if (STATE->trials == 1)
{
u32 n = 0, i;
for (i = lo; i < hi; i++)
if (!reject(i))
n++;
STATE->trials = n;
PrintTestName();
}
STATE->trialRatio = Q_4_12(1) / STATE->trials;
while (reject(STATE->runTrial + lo + STATE->rngTrialOffset))
{
if (STATE->runTrial + lo + STATE->rngTrialOffset > hi)
Test_ExitWithResult(TEST_RESULT_INVALID, "RandomUniformExcept called with inconsistent reject");
STATE->rngTrialOffset++;
}
return STATE->runTrial + lo + STATE->rngTrialOffset;
}
default_ = hi;
while (reject(default_))
{
if (default_ == lo)
Test_ExitWithResult(TEST_RESULT_INVALID, "RandomUniformExcept rejected all values");
default_--;
}
return default_;
}
u32 RandomWeightedArray(enum RandomTag tag, u32 sum, u32 n, const u8 *weights)
{
const struct BattlerTurn *turn = NULL;
u32 default_ = n-1;
if (sum == 0)
Test_ExitWithResult(TEST_RESULT_ERROR, "RandomWeightedArray called with zero sum");
if (gCurrentTurnActionNumber < gBattlersCount)
{
u32 battlerId = gBattlerByTurnOrder[gCurrentTurnActionNumber];
turn = &DATA.battleRecordTurns[gBattleResults.battleTurnCounter][battlerId];
}
if (turn && turn->rng.tag == tag)
{
default_ = turn->rng.value;
}
else
{
switch (tag)
{
case RNG_ACCURACY:
ASSUME(n == 2);
if (turn && turn->hit)
return turn->hit - 1;
default_ = TRUE;
break;
case RNG_CRITICAL_HIT:
ASSUME(n == 2);
if (turn && turn->criticalHit)
return turn->criticalHit - 1;
default_ = FALSE;
break;
case RNG_SECONDARY_EFFECT:
ASSUME(n == 2);
if (turn && turn->secondaryEffect)
return turn->secondaryEffect - 1;
default_ = TRUE;
break;
}
return turn->rng.value;
}
if (tag == STATE->rngTag)
@ -397,7 +416,38 @@ u32 RandomWeightedArray(enum RandomTag tag, u32 sum, u32 n, const u8 *weights)
return STATE->runTrial;
}
return default_;
switch (tag)
{
case RNG_ACCURACY:
ASSUME(n == 2);
if (turn && turn->hit)
return turn->hit - 1;
else
return TRUE;
case RNG_CRITICAL_HIT:
ASSUME(n == 2);
if (turn && turn->criticalHit)
return turn->criticalHit - 1;
else
return FALSE;
case RNG_SECONDARY_EFFECT:
ASSUME(n == 2);
if (turn && turn->secondaryEffect)
return turn->secondaryEffect - 1;
else
return TRUE;
default:
while (weights[n-1] == 0)
{
if (n == 1)
Test_ExitWithResult(TEST_RESULT_ERROR, "RandomWeightedArray called with all zero weights");
n--;
}
return n-1;
}
}
const void *RandomElementArray(enum RandomTag tag, const void *array, size_t size, size_t count)
@ -409,8 +459,6 @@ const void *RandomElementArray(enum RandomTag tag, const void *array, size_t siz
{
u32 battlerId = gBattlerByTurnOrder[gCurrentTurnActionNumber];
turn = &DATA.battleRecordTurns[gBattleResults.battleTurnCounter][battlerId];
}
if (turn && turn->rng.tag == tag)
{
u32 element = 0;
@ -418,13 +466,10 @@ const void *RandomElementArray(enum RandomTag tag, const void *array, size_t siz
{
memcpy(&element, (const u8 *)array + size * index, size);
if (element == turn->rng.value)
break;
return (const u8 *)array + size * index;
}
if (index == count)
{
// TODO: Incorporate the line number.
const char *filename = gTestRunnerState.test->filename;
Test_ExitWithResult(TEST_RESULT_ERROR, "%s: RandomElement illegal value requested: %d", filename, turn->rng.value);
Test_ExitWithResult(TEST_RESULT_ERROR, "%s: RandomElement illegal value requested: %d", gTestRunnerState.test->filename, turn->rng.value);
}
}
@ -440,10 +485,8 @@ const void *RandomElementArray(enum RandomTag tag, const void *array, size_t siz
Test_ExitWithResult(TEST_RESULT_ERROR, "RandomElement called with inconsistent trials %d and %d", STATE->trials, count);
}
STATE->trialRatio = Q_4_12(1) / count;
index = STATE->runTrial;
return (const u8 *)array + size * STATE->runTrial;
}
return (const u8 *)array + size * index;
}
static s32 TryAbilityPopUp(s32 i, s32 n, u32 battlerId, u32 ability)
@ -962,6 +1005,7 @@ void Randomly(u32 sourceLine, u32 passes, u32 trials, struct RandomlyContext ctx
INVALID_IF(test->resultsSize > 0, "PASSES_RANDOMLY is incompatible with results");
INVALID_IF(passes > trials, "%d passes specified, but only %d trials", passes, trials);
STATE->rngTag = ctx.tag;
STATE->rngTrialOffset = 0;
STATE->runTrial = 0;
STATE->expectedRatio = Q_4_12(passes) / trials;
STATE->observedRatio = 0;