Skip to content

Maintainer notes

The problem

Raid boss selection is a flat weighted draw:

// RaidRandomizer.getSpawnsForBiome
for (RaidSpawn spawn : raidSpawnSet.spawns())
    if (spawn.biomes().contains(biome))
        spawns.add(spawn.weight(), spawn);   // WeightedSet

Every one of Pixelmon's 1080 stock raid spawn entries ships "weight": 1.0, so a legendary's share of den rolls is just its head-count share of whatever entries that biome happens to have. Measured across 9.3.16's data:

legendary share
median biome 6.73% (1 in 15)
worst (frozen_peaks, End) 18.6%
best ~4%

The only stock lever is raid-have-legendaries, which is all-or-nothing.

Why a datapack can't fix the variance

RaidSpawn.CODEC declares weight as Codec.DOUBLE and pixelmon:raid/spawn is a NeoForge datapack registry, so weights are editable from a datapack, that is how the companion snowbird_raid_legendary_rates pack cuts the overall rate 50×.

But a weight applies to every biome its biome tag covers, and those tag sets overlap heavily, so biomes are not independently addressable. Solving for the best possible per-entry weights (least-squares against a uniform target, 88 legendary entries × 203 biomes) gets the spread from 3.87× down to only 2.06×, and gets there by driving several entries to nearly zero, silently deleting those species.

Uniformity has to be imposed where the biome is known: at roll time.

How it works

RaidRandomizer posts RandomizeRaidEvent.SelectSpawnsEvent and then keeps using its own local spawns reference, which is the same object the event carries. SelectSpawnsEvent exposes a getter and no setter, the tell that in-place mutation is the intended extension point. So per den roll:

  1. snapshot the set, split it into legendary-tier, Ultra Beast and everything else
  2. solve all controlled groups jointly for their target shares
  3. clear(), re-add uncontrolled entries unchanged and each controlled group at weight * scale

Relative weights within each group are preserved; only the group's total moves. The result is exactly legendaryShare and ultraBeastShare in every biome.

The solve is joint, not one group after another. Let T be the total weight afterwards: each controlled group i must end at t_i * T, and the uncontrolled remainder N is never scaled, so N = (1 - Σt_i) * T and therefore T = N / (1 - Σt_i), giving s_i = t_i * T / w_i. Running the one-group solve twice instead misses both targets, because scaling one group changes the denominator for the other. LegendaryRateMathTest.jointSolveIsNotSequential pins it.

Ultra Beasts are a third, disjoint set (1.2.0)

Species.isLegendary(), isMythical() and isUltraBeast() are three separate dex-set lookups. The original classifier isLegendary() || isMythical() is therefore false for all eleven Ultra Beasts, and they stayed at Pixelmon's stock weight while legendaries were being cut 63×. All eleven ship at weight 1.0 and 5 stars, and to a player a 5-star Ultra Beast raid is a legendary raid.

Measured across 9.3.16 and confirmed live:

Ultra Beast share of den rolls
pixelmon:ultra_desert 4.44%, the only biome with two UB entries
minecraft:the_end 2.37%
median, all 217 biomes with a UB entry 1.03%

That is 10× the 0.1% legendary target globally and 44× in Ultra Space, which is why the legendary rate looked untouched after the mod was installed. raid-have-legendaries does not gate them either, they have their own raid-have-ultra-beasts flag.

Per-dimension rates and population scaling (1.3.0)

The share this mod pins is per den roll. It cannot equalise rolls per hour, and that is what players actually experience, Ultra Space concentrates play into a small dense area, so the same 0.1% produced every legendary raid seen on main in the 6.9 hours after its 2026-08-08 restart, and none anywhere else. Measured directly: 3,264 forced den rolls in Ultra Space produced 4 legendary raids = 0.1225%, against a 0.1000% target. The rate was correct; the rate was the wrong rate.

Two knobs close that gap.

Per dimension. [rate.perDimension] overrides takes "<dimension>=<legendaryShare>[,<ultraBeastShare>]" lines. Omitting the second value (or writing -) inherits the global Ultra Beast share, it does not zero it, because 0.0 would silently ban Ultra Beasts in that dimension. A bare overworld is expanded to minecraft:overworld; a missing namespace is the thing everyone forgets, and an unmatched override looks exactly like the feature being broken.

By population. Den rolls scale with how many dens are chunk-loaded, which scales with how many players are online, so:

effective = share * (referencePlayers / max(referencePlayers, players)) ^ exponent

clamped so it never falls below minimumShare and never rises above the configured share. The upper clamp is not decoration: a minimumShare above a configured share would otherwise raise that rate, which is the same floor-not-ceiling mistake this mod already shipped once.

exponent = 1.0 is fully proportional and holds the server-wide sighting rate roughly constant; 0.5 is gentler; 0.0 is off. scope = DIMENSION counts only players in the den's own dimension - more accurate, since rolls in Ultra Space are driven by players in Ultra Space, but then a quiet dimension does not tighten when the server is busy.

/raidlegendaryrate simulate <players> prints the curve for the dimension you are standing in, so a change can be checked before the server fills up rather than after.

Reloading config without a restart

/raidlegendaryrate reload re-reads the TOML and applies it live, printing each key: old -> new it changed.

This is not just a convenience wrapper. A NeoForge ModConfigSpec.ConfigValue caches on first get() and only drops that cache when something calls clearCache(), so editing the file and doing nothing else leaves the mod running its boot-time values, verified on mcnet, where the server kept reporting the old share after the file changed. NeoForge's own file watcher does fire on a change, but it is a filesystem-event race that is easy to miss over SFTP and reports nothing either way.

Two things the implementation has to get right:

  • Validate before applying. ConfigValue.set() deliberately bypasses the spec's correction pass, so it will happily install an out-of-range number. Ranges and the cross-field sum rule are checked first, and nothing is applied if any of it fails.
  • TOML integers arrive as Long. legendaryShare = 0 parses to a Long, so a straight Double cast throws. Values are read through Number.doubleValue().

Rates apply to dens that roll after the reload; a den already sitting in the world keeps its boss until it is beaten and respawns.

Traps

  • RandomizeRaidEvent is posted on Pixelmon.EVENT_BUS, a standalone BusBuilder instance, not NeoForge.EVENT_BUS. Registering on the NeoForge bus compiles, loads cleanly, and silently never fires.
  • Never read weights from WeightedSet.getWeight(). It is backed by a HashMap<T, Double>, so two equal RaidSpawn records collapse and the second weight is lost. Read RaidSpawn.weight() off the record.
  • Never let a rescaled weight reach exactly 0. WeightedSet.add() early-returns on zero, dropping the entry instead of making it rare. Clamped to 1e-9.
  • Classification is species-level, via Species.isLegendary() || isMythical(). That is a dex-set lookup into PixelmonSpecies.LEGENDARIES / MYTHICALS, which are built by scanning any form of a dex for the tag. It is deliberately not Pokemon.isLegendary(), which reads the individual form's tags and therefore misses Deoxys-Speed/Defense, Hoopa-Unbound, the Therian genies, Shaymin-Sky and Zygarde-10% - all tags: []. That gap is why those forms spawned even with raid-have-legendaries: false.
  • PokemonSpecification has no species accessor. spec.create(true) is the shallow build: it applies the requirements that set the species but skips Pokemon.initialize(). Results are cached by the spec string (AbstractSpecification.toString() returns the original spec).
  • The mod is a no-op while raid-have-legendaries: false, RaidRandomizer.java:50 re-rolls past every legendary before weights matter.

Relationship to the datapack

manual_uploads/2026-08-06-raid-legendary-rates-events/snowbird_raid_legendary_rates.zip does two things: bans 10 species outright, and sets legendary weights to 0.02.

Once this mod is loaded the 0.02 is redundant, a uniform scale factor cancels out under renormalisation. The pack still carries the bans, which this mod does not do. If the mod is removed the 0.02 becomes live again, so the pack is also the fallback.