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:
- snapshot the set, split it into legendary-tier, Ultra Beast and everything else
- solve all controlled groups jointly for their target shares
clear(), re-add uncontrolled entries unchanged and each controlled group atweight * 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 = 0parses to aLong, so a straightDoublecast throws. Values are read throughNumber.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¶
RandomizeRaidEventis posted onPixelmon.EVENT_BUS, a standaloneBusBuilderinstance, notNeoForge.EVENT_BUS. Registering on the NeoForge bus compiles, loads cleanly, and silently never fires.- Never read weights from
WeightedSet.getWeight(). It is backed by aHashMap<T, Double>, so two equalRaidSpawnrecords collapse and the second weight is lost. ReadRaidSpawn.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 to1e-9. - Classification is species-level, via
Species.isLegendary() || isMythical(). That is a dex-set lookup intoPixelmonSpecies.LEGENDARIES/MYTHICALS, which are built by scanning any form of a dex for the tag. It is deliberately notPokemon.isLegendary(), which reads the individual form's tags and therefore misses Deoxys-Speed/Defense, Hoopa-Unbound, the Therian genies, Shaymin-Sky and Zygarde-10% - alltags: []. That gap is why those forms spawned even withraid-have-legendaries: false. PokemonSpecificationhas no species accessor.spec.create(true)is the shallow build: it applies the requirements that set the species but skipsPokemon.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:50re-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.