Shiny.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
-- Shiny Pokemon: the one fact, and everywhere that asks it. -- -- WHAT MAKES A MON SHINY HERE IS ITS DVs, and nothing else. Gen 1 has no -- shininess of its own, but it does have the four DVs Gen 2 later read to -- decide it, and the engine already ships that reading: -- src/pokemon/Stats.lua:90 isShiny(dvs) -- Defense, Speed and Special all -- exactly 10, Attack one of 2/3/6/7/10/11/14/15. The engine's own comment -- calls it "the RBY virtual shiny" and says it is there for indicator mods. -- This is that mod. -- -- Deriving rather than storing is the whole design, and it buys a great -- deal: -- -- * It persists for free. DVs are already in every save, every PC box, -- every trade. No new save field, no migration, and a save made before -- this mod was installed already HAS shiny Pokemon in it -- they were -- always there, nothing was ever drawn differently. -- * It survives evolution. Evolution.apply recalculates stats from the -- same dvs table and never touches it (src/pokemon/Evolution.lua:99), -- so a shiny Bulbasaur is a shiny Venusaur without being told. -- * It cannot desync. A flag stored beside the DVs is a second copy of -- the truth, and two copies drift -- most cruelly across a trade or a -- box deposit, where the mon travels and the sidecar does not. -- * PKHeX and the Gen 2 games agree with us, because it is their rule. -- -- The odds, though, are ours to set, and that is the one thing DVs alone -- cannot give: random DVs land on that pattern 1/16 * 1/16 * 1/16 * 8/16 = -- exactly 1/8192, the classic rate, and there is no dial on it. So the roll -- happens at encounter time and its VERDICT IS WRITTEN BACK INTO THE DVs -- (forceShiny/forceCommon below). The mon does not carry a flag saying it -- is shiny; it is made genuinely shiny by the game's own formula, and every -- later reader -- ours, the engine's, a future mod's, PKHeX's -- reaches the -- same answer without knowing we were involved. -- -- mon.shiny is maintained too, but it is a CACHE and never the source: see -- Shiny.mark. -- the mod namespace (see main.lua): V.require loads a sibling module local V = ... -- allowlisted for mods by name -- src/mods/Loader.lua:71 lists -- src.pokemon.Stats precisely so an indicator mod can call isShiny local Stats = require("src.pokemon.Stats") local ModSetting = V.require("ModSetting") local Shiny = {} -- ------- the odds -- -- One in ODDS_DENOM. The default is 8192 because that is what random DVs -- already produce, so a player who never changes it gets the canonical rate -- and the canonical feel -- this mod's default is not a buff. -- -- The roll is made EXACT rather than additive. A naive implementation rolls -- 1/N and forces shiny on a hit, but leaves the natural 1/8192 in place on a -- miss, so the true rate is N and 8192 in parallel -- indistinguishable at -- the default and quietly wrong at every other setting (at 1/100 you would -- ship 1/99.99, and at 1/20000 you could never go rarer than 1/8192 no -- matter what you set). forceCommon on a miss closes that: the rate is what -- the number says. Shiny.ODDS_DENOM = 8192 -- ------- the row the player cycles -- -- A ladder that HALVES, so every step is exactly "twice as often as the one -- above it" and the label says the whole truth -- 1:8192 down to 1:1. The -- rate is what the number says, not an approximation of it, because the -- miss branch of decide() closes the natural 1/8192 (see above); a rung of -- 1:2 really is every other encounter. -- -- values[1] is 8192: ModSetting treats the first rung as both the DEFAULT -- and the fallback for an unreadable or unrecognised stored value, so the -- canonical rate is what a player who never opens the menu gets and what a -- corrupted options.lua comes back to. -- -- No rung RARER than 8192. The mod's promise is that its default is not a -- change to the game; making the game harder than it ships is a different -- promise and nobody asked for it. local ODDS = { 8192, 4096, 2048, 1024, 512, 256, 128, 64, 32, 16, 8, 4, 2, 1 } local ODDS_LABELS = {} for i, n in ipairs(ODDS) do ODDS_LABELS[i] = "1:" .. n end Shiny.setting = ModSetting.new("shinyOdds", "SHINY ODDS", ODDS, ODDS_LABELS) -- ------- the setting is PULLED, not pushed -- -- decide() asks this every roll rather than the menu telling us when it -- changed. Two writers exist -- the OPTIONS row and the mod manager's own -- settings page -- and only the first has a change hook to hang on; the -- manager writes through mod.options and calls ModSetting:sync, which -- notifies nothing. Pulling is the only way both are seen, and the cost is -- a table read on an event that happens once per encounter. -- -- ODDS_DENOM stays the live value and is written through on every ask, so -- anything already reading that field keeps reading the truth. local pinned = false function Shiny.odds() if not pinned then local ok, value = pcall(Shiny.setting.get, Shiny.setting) local n = ok and tonumber(value) if n and n >= 1 then Shiny.ODDS_DENOM = math.floor(n) end end return Shiny.ODDS_DENOM end -- Set the denominator BY HAND, which also PINS it: a driver or a test that -- has asked for 1:1 means it, and must not have the next roll quietly put -- back to whatever the player left on the menu. Nothing in the game calls -- this -- the row is how a player changes the rate. -- -- Guards the degenerate values because a 0 or a negative here would -- divide-by-zero or make every encounter shiny by accident rather than by -- choice; 1 (always shiny) stays reachable because it is genuinely useful -- for walking the whole model set. function Shiny.setOdds(denom) denom = tonumber(denom) if not denom or denom < 1 then return Shiny.ODDS_DENOM end Shiny.ODDS_DENOM = math.floor(denom) pinned = true return Shiny.ODDS_DENOM end -- Hand the row back control, for a test that pinned the odds and wants the -- setting to mean something again afterwards. function Shiny.unpinOdds() pinned = false return Shiny.odds() end -- ------- reading it -- The eight Attack DVs that satisfy the Gen 2 pattern, in order. Kept as a -- list as well as the engine's set because forceShiny has to CHOOSE one and -- wants the nearest, not just any. local SHINY_ATK = { 2, 3, 6, 7, 10, 11, 14, 15 } -- The HP DV is not free: Gen 1 derives it from the low bit of each of the -- other four (src/pokemon/Stats.lua:19). Any write to the four must -- recompute it, or the mon ends up with an HP stat the real game could -- never produce -- which is exactly what a save inspector flags as illegal. local function syncHpDv(dvs) dvs.hp = (dvs.attack % 2) * 8 + (dvs.defense % 2) * 4 + (dvs.speed % 2) * 2 + (dvs.special % 2) return dvs end -- The single question. Everything visual in this mod routes here. function Shiny.isShiny(mon) if type(mon) ~= "table" then return false end return Stats.isShiny(mon.dvs) == true end -- ------- writing it -- Make these DVs satisfy the pattern, moving them as little as it allows. -- -- Defense, Speed and Special have exactly one legal value each, so they are -- simply pinned. Attack has eight, and the nearest one to whatever was -- rolled is chosen -- a mon rolled at Attack 15 keeps 15, one rolled at 0 -- becomes 2. That is not cosmetic: DVs are stats, and a shiny encounter -- should not also be a stat reroll any larger than the pattern demands. local function forceShiny(dvs) local want, best, bestd = dvs.attack or 0, SHINY_ATK[1], nil for _, v in ipairs(SHINY_ATK) do local d = math.abs(v - want) if not bestd or d < bestd then bestd, best = d, v end end dvs.attack = best dvs.defense, dvs.speed, dvs.special = 10, 10, 10 return syncHpDv(dvs) end -- Make these DVs NOT satisfy the pattern, moving them as little as -- possible: one step on Special is enough to break it, and Special is the -- choice because in Gen 1 it is a single stat rather than the two Gen 2 -- split it into, so the disturbance stays inside one number. -- -- Only ever reached by a mon that rolled non-shiny and happened to be shiny -- by luck, which is 1/8192 of the time -- so this touches almost nothing, -- and what it does touch it moves by one point. local function forceCommon(dvs) if (dvs.special or 0) == 10 then dvs.special = 9 elseif (dvs.defense or 0) == 10 then dvs.defense = 9 end return syncHpDv(dvs) end -- mon.shiny: the cache. -- -- The requirement is a flag ON the Pokemon, and this is it -- but it is -- written from the DVs every time we touch a mon, never read as the truth. -- Keeping it one-directional is what stops it becoming the second copy the -- header warns about: if it ever disagrees with the DVs, the DVs win and -- this is overwritten. It exists so other code -- and a save inspector, and -- a companion mod -- can ask the cheap question without importing Stats. function Shiny.mark(mon) if type(mon) ~= "table" then return false end local is = Shiny.isShiny(mon) mon.shiny = is or nil -- nil rather than false: absent keeps saves clean return is end -- Recalculate the stats a DV write invalidated. -- -- Split out because both decide() and set() move DVs, and a mon left -- carrying stats computed from its old DVs is wrong in the only way the -- player can actually see: its HP bar. local function restat(mon) if not (mon.level and mon.species) then return end local ok, data = pcall(require, "src.core.Data") local def = ok and data and data.pokemon and data.pokemon[mon.species] if not def then return end local wasFull = mon.hp and mon.stats and mon.hp >= (mon.stats.hp or 0) mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp) -- A wild mon appears at full health, and a mon that WAS full stays full: -- recomputing max HP without following it here would put a freshly -- encountered mon on the field at less than full from its first frame. -- A wounded mon keeps its damage, clamped to the new maximum. if mon.hp then mon.hp = wasFull and mon.stats.hp or math.min(mon.hp, mon.stats.hp) end end -- ------- our own randomness -- -- A PRIVATE stream, not love.math.random, and that is deliberate. -- -- The game's RNG is a shared sequence: damage rolls, crits, encounter -- slots and DV generation all draw from it in a fixed order. Taking a draw -- out of it for a shiny check would shift every later draw, so installing -- this mod would quietly change the outcome of fights that have nothing to -- do with shininess -- and the manifest promises `affects_link = false`, -- which a shifted stream would make untrue the moment two machines -- disagreed about whose turn consumed what. -- -- Seeded off the clock rather than the save, because shininess is a fact -- about the encounter and not about the file: re-loading a save to re-roll -- a Pokemon is the hunt, and a stream keyed to the save would hand back the -- same answer every time. local stream = nil local function roll(n) if not stream then if love and love.math and love.math.newRandomGenerator then stream = love.math.newRandomGenerator(os.time(), os.clock() * 1e6) else -- headless (tests): math.random is nobody's shared sequence there stream = { random = function(_, a, b) return math.random(a, b) end } end end return stream:random(1, n) end -- Decide a freshly-built mon, in place. -- -- rng may be passed to pin the verdict -- a test hands us a stub. Left nil, -- the private stream above is used. function Shiny.decide(mon, rng) if type(mon) ~= "table" or type(mon.dvs) ~= "table" then return false end -- same shape as love.math.random(lo, hi), so a caller can pass that or a -- stub and the call below reads identically either way rng = rng or function(_lo, hi) return roll(hi) end local hit = rng(1, Shiny.odds()) == 1 if hit then forceShiny(mon.dvs) restat(mon) elseif Stats.isShiny(mon.dvs) then forceCommon(mon.dvs) restat(mon) end return Shiny.mark(mon) end -- Force a specific verdict: for tests, and for a scripted gift mon that -- wants to be shiny on purpose. function Shiny.set(mon, on) if type(mon) ~= "table" or type(mon.dvs) ~= "table" then return false end if on then forceShiny(mon.dvs) else forceCommon(mon.dvs) end restat(mon) return Shiny.mark(mon) end return Shiny