shiny Pokemon, on by default

Gen 1 has no shininess of its own, but it has the four DVs Gen 2 reads to
decide it -- and the engine already ships that reading (Stats.isShiny, its
own comment calling it "the RBY virtual shiny", allowlisted for mods
precisely so an indicator mod can call it). Nothing new is stored on a
Pokemon and nothing migrates: every save already contains the answer, and
this starts drawing it. Random DVs land on the pattern 1 in 8192, which is
the classic rate and the default the odds dial ships at.

Deriving rather than storing is what makes it survive a save, a box, a
trade and an evolution with no second copy of the truth to drift. mon.shiny
is a cache written from the DVs, never read as the source.

The roll goes in Pokemon.new -- every wild, gift, starter and traded mon is
built there, and it is before the battle bakes its sprite, which
battle.started is already too late for. It draws from the mod's own random
stream so installing this does not shift the sequence damage rolls and
encounter slots come out of. Trainers stay ordinary by themselves: the
engine pins their DVs, as the real games do.

The models are genuinely recoloured, as part of the extraction. Each
species is decoded once, packed as usual, then recoloured and packed again
as NNNs.dsm. The colours are Stadium's own HSL slide (hue in degrees,
saturation and lightness on a -8..+8 scale at 12.5% a step); five species
carry an explicit colour table instead, because Stadium gives them a real
alternate texture that no single slide reproduces -- Jigglypuff's body must
stay pink while its irises rotate to green.

Extraction is the right moment because StadiumFx's generated frames are
still marked there and the packer drops the marker: it is the last point a
flame is distinguishable from a hide. A shiny Charizard has a shiny hide
and an ordinary fire. The normal packs are written BEFORE the recolour, so
they come out byte-identical and stadium_extract_test still diffs all 151
against the Python oracle unchanged -- no format change, no DSM4, no second
implementation to keep in step. REV goes to 3 so an existing cache rebuilds.

Flat art is tinted instead, because the engine bakes a species palette into
a cache with no notion of which individual is drawn. The tint comes from
that species' own slide rather than a generic gold. A multiply can only
darken, so species whose shiny is lighter read quieter there than on the
model; the status page's star is the mode-proof mark.

Tests: 58 assertions in tests/shiny_test.lua, including the colour
transform against 640 real colour pairs lifted from the verified texture
set, the DV model, the read side, and the end-to-end through the engine's
own constructor. stadium_extract_test gains --mod (worktrees have neither
the ROM nor the packs, both gitignored) and now also checks that every
shiny pack is the same length as its twin and actually differs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DramaticShape
2026-08-08 12:12:25 -04:00
parent 121c87b629
commit a3fb18a589
19 changed files with 3773 additions and 34 deletions
+26 -3
View File
@@ -51,6 +51,8 @@ local V = ...
local Voxel3D = V.require("Voxel3D")
local StadiumPack = V.require("StadiumPack")
local StadiumMon = V.require("StadiumMon")
local ShinyBattle = V.require("ShinyBattle")
local ShinyFx = V.require("ShinyFx")
local Stadium = {}
@@ -370,6 +372,15 @@ function Stadium.update(dt, battle, groundY)
-- a fresh arrival: this Pokemon has not grown out of its ball yet
if mon then mon.grow, mon.grewOwn = nil, nil end
if mon and mon.rig and mon.state == "faint" then mon:play("idle") end
-- and if it is shiny, announce it. This edge rather than the grow,
-- because a WILD foe never grows -- it is on the field from the
-- first frame -- and that is the encounter a shiny most wants to be
-- announced on. See the header of ShinyFx.
if ShinyBattle.battlerIsShiny(battler) then
ShinyFx.arm(side)
else
ShinyFx.clear(side)
end
end
-- the collapse this side is owed, once its bar has finished emptying
if session.faintPending and session.faintPending[side] then
@@ -381,14 +392,26 @@ function Stadium.update(dt, battle, groundY)
end
end
mon:setSpecies(dex)
-- Shininess is a property of the OCCUPANT, not of the species, so it is
-- resolved here beside the dex number and passed with it. A shiny
-- Rattata and an ordinary one are the same dex and different models.
--
-- Read off the battler rather than remembered, because Transform makes
-- the two disagree: a Ditto that copied a shiny Rattata wears the
-- Rattata's dex (session.transform above) and keeps its OWN shininess,
-- which is exactly what the games do.
local shiny = battler ~= nil and not session.transform[side]
and ShinyBattle.battlerIsShiny(battler)
mon:setSpecies(dex, shiny)
-- and tell the pack cache this one is standing there, every frame. Its
-- eviction order is keyed on LOADS, and a side only loads when its
-- species changes -- so without this a Pokemon that has been out for a
-- few turns is the least recently loaded thing in the cache and gets its
-- textures released out from under it the moment a fifth species enters
-- the battle (see StadiumPack.keep).
if mon.species then StadiumPack.keep(mon.species) end
-- the battle (see StadiumPack.keep). The shiny flag rides along: the
-- shiny and normal models are separate cache entries.
if mon.species then StadiumPack.keep(mon.species, mon.shiny) end
mon.visible = (mon.rig ~= nil) and onField(battle, side, mon)
and not (battler and battler.substituteHP)
-- LET'S GO capture mode: the player's model is out of the shot the