StadiumInstall.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
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
-- STADIUM battles: finding the ROM, and building the models out of it once. -- -- The mod does not ship the Pokemon Stadium models and cannot: they are that -- game's data. What it ships is the READER -- StadiumRom, StadiumFragment, -- StadiumFx and StadiumBuild -- and the player supplies the cartridge, which -- is exactly the arrangement this engine already has for the Game Boy ROM it -- is a recompilation of (src/import/RomImporter.lua). -- -- So: supply a Pokemon Stadium (US) 1.0 ROM -- the OPTIONS row opens a file -- picker for one, or drop it in `baseroms/` -- and the first time the -- game runs with the mod on, the models are built. Once, on a loading screen, -- in about ten seconds. After that the packs sit in the save directory and -- the mod reads them like any other asset. -- -- ------- where "baseroms/" is -- -- One relative path, and it deliberately covers two different places at once, -- because PhysFS searches the save directory AND the game folder under the -- same names: -- -- * a folder install, or a checkout -- `baseroms/` next to main.lua -- * a packaged or fused build, where the game folder is inside an archive -- and cannot be written to -- `baseroms/` in the save directory, whose -- absolute path this reports on screen so it can be found -- -- The file goes STRAIGHT IN THERE, with no revision subfolder under it. The -- decompilation's own `make init` uses `baseroms/us/`, and the offline -- pipeline under model_extract/ still reads from there because it shares that -- tree -- but the instruction given to a player is "drop the file in this -- folder", and one folder is the whole of it. -- -- Any of `.z64`, `.n64` and `.v64` is accepted; StadiumRom normalises the -- byte order on load. -- -- ------- what "installed" means -- -- A marker file next to the packs, holding the format magic, how many species -- were written and the md5 of the ROM they came from. All three matter. The -- magic catches a format change (the packs are rebuilt rather than read as -- garbage), the count catches a build that was interrupted half way, and the -- md5 catches the player swapping the ROM for a different revision. -- the mod namespace (see main.lua): V.require loads a sibling module local V = ... local StadiumPack = V.require("StadiumPack") local StadiumInstall = {} -- Where a ROM is looked for, and where the built packs are kept. StadiumInstall.ROM_DIR = "baseroms" StadiumInstall.DIR = StadiumPack.CACHE_DIR StadiumInstall.MARKER = StadiumInstall.DIR .. "/pack.info" -- Bumped whenever the .dsm format changes, so an old cache is rebuilt rather -- than misread. Must track StadiumPack's magic. StadiumInstall.FORMAT = "DSM3" -- Bumped when the packs' CONTENT changes without the byte layout moving, so -- a cache built by an older extractor is rebuilt rather than trusted. Rev 2 -- is the hermite-animation decode fix: the five keyframe species (Pidgeot, -- Dodrio, Exeggutor, Tangela, Magmar) come out garbled or bind-posed from -- any rev-1 build. -- -- Rev 3 adds the shiny variants (NNNs.dsm). The normal packs are unchanged -- byte for byte, so this is exactly the case REV exists for and not a FORMAT -- bump: nothing about DSM3 moved, there is simply a second file per species -- that a rev-2 cache does not have. Without the bump a player who already -- installed would keep a complete-looking cache with no shiny models in it, -- and every shiny they met would silently show its normal colours. StadiumInstall.REV = 3 StadiumInstall.COUNT = 151 -- Named ROM files, then any ROM at all sitting in the folder. -- -- Flat in `baseroms/`, with no revision subfolder: the offline pipeline under -- model_extract/ keeps the decompilation's own `baseroms/us/` convention -- because it shares that tree, but what is being asked of a PLAYER here is -- "drop the file in this folder", and one folder is the whole of that -- instruction. A path they have to build out of two parts is a path half of -- them will get wrong, and the failure is silent -- the rungs are simply not -- on the row. local NAMED = { StadiumInstall.ROM_DIR .. "/baserom.z64", StadiumInstall.ROM_DIR .. "/baserom.n64", StadiumInstall.ROM_DIR .. "/baserom.v64", } local function fs() return love and love.filesystem end local function isFile(path) local f = fs() if not (f and f.getInfo) then return false end local ok, info = pcall(f.getInfo, path, "file") return (ok and info) and true or false end -- The ROM's path on the PhysFS read path, or nil. function StadiumInstall.romPath() local f = fs() if not f then return nil end for _, path in ipairs(NAMED) do if isFile(path) then return path end end local ok, items = pcall(f.getDirectoryItems, StadiumInstall.ROM_DIR) if ok and items then table.sort(items) for _, name in ipairs(items) do if name:lower():match("%.[nvz]64$") then local path = StadiumInstall.ROM_DIR .. "/" .. name if isFile(path) then return path end end end end return nil end function StadiumInstall.romPresent() return StadiumInstall.romPath() ~= nil end -- Where to tell the player to put it. The save directory is the answer that -- is always writable, and it is the one a packaged build needs. function StadiumInstall.romHint() local f = fs() local base = (f and f.getSaveDirectory and select(2, pcall(f.getSaveDirectory))) if type(base) ~= "string" then base = "the game folder" end return base .. "/" .. StadiumInstall.ROM_DIR end -- The same thing with a FILENAME on the end, which is what a player actually -- needs: a folder alone leaves them guessing what to call the file, and the -- guess is not obviously "baserom.z64". -- -- Taken from the head of NAMED rather than retyped, so the name shown is by -- construction the first name looked for. It is not the ONLY one that works -- -- `.n64` and `.v64` are accepted, and so is any other name carrying one -- of those extensions -- but an instruction that names one file is one a -- player can follow, and an instruction that lists every possibility is one -- they have to interpret. function StadiumInstall.romHintFile() return StadiumInstall.romHint() .. "/" .. (NAMED[1]:match("[^/]+$") or "") end -- ------- the marker local function readMarker() local f = fs() if not (f and isFile(StadiumInstall.MARKER)) then return nil end local ok, text = pcall(f.read, StadiumInstall.MARKER) if not (ok and type(text) == "string") then return nil end local format, count, md5, rev = text:match("^(%S+)%s+(%d+)%s*(%S*)%s*(%S*)") if not format then return nil end return { format = format, count = tonumber(count), md5 = md5, rev = tonumber(rev) } end -- Whether a complete, current set of packs is on disk. local readyCache = nil function StadiumInstall.ready() if readyCache ~= nil then return readyCache end local m = readMarker() readyCache = (m ~= nil and m.format == StadiumInstall.FORMAT and m.count == StadiumInstall.COUNT and m.rev == StadiumInstall.REV) and true or false return readyCache end -- Whether a complete set came WITH the mod folder -- a developer checkout -- that has run tools/stadium_pack.py. Never true of a released build, which -- carries no models at all. -- -- Sampled at both ends of the dex rather than counted. The question being -- asked is "did somebody run the packer here", not "is every one of the 151 -- present"; a genuinely half-written folder is a case for the marker file, -- which is what catches an interrupted RUNTIME build. local function shipped() local mod = V.mod if not (mod and mod.read) then return false end for _, dex in ipairs({ 1, 151 }) do local ok, bytes = pcall(mod.read, mod, ("%s/%03d.dsm"):format(StadiumPack.DIR, dex)) if not (ok and type(bytes) == "string" and #bytes > 4) then return false end end return true end -- Whether the packs on disk can be READ, even if they are not current. -- -- Format and count, but deliberately NOT rev. The distinction matters on an -- upgrade: a rev bump means the packs are out of date, not that they are -- unreadable, and treating the two the same is what would make the STADIUM -- rungs disappear off the options row for anyone whose cache predates it. -- Losing the recolour until a rebuild is a blemish; losing the mode is not. function StadiumInstall.usable() local m = readMarker() return (m ~= nil and m.format == StadiumInstall.FORMAT and m.count == StadiumInstall.COUNT) and true or false end -- Whether the STADIUM rungs can be offered at all: the packs have been built -- from the player's ROM (current or merely readable), or the mod folder -- already carries a set. function StadiumInstall.available() if StadiumInstall.ready() then return true end if StadiumInstall.usable() then return true end return shipped() end -- Whether there is work to do: a ROM to build from, and no CURRENT set. -- -- Keyed on ready() rather than available(), and that is the whole upgrade -- story. It used to short-circuit on available(), which meant a checkout -- carrying assets/stadium was never pending -- so when REV went to 3 for the -- shiny variants, such a machine did not rebuild, was not asked to, and -- quietly kept serving the old set: every shiny Pokemon drawn in its -- ordinary colours, with nothing on screen to say why. That is exactly what -- happened here, and it took a driver run sitting at "idle 0/151" to notice. -- -- The cost this trades away is real and was the original reason: a checkout -- with a ROM now spends one loading screen rebuilding a set it already had -- files for. Once. After that ready() is true and it is not pending again -- -- and what it buys is that a rev bump actually reaches the people it was -- bumped for. function StadiumInstall.pending() if not StadiumInstall.romPresent() then return false end return not StadiumInstall.ready() end function StadiumInstall.forget() readyCache = nil end -- ------- building local job = nil local status = { state = "idle", done = 0, total = StadiumInstall.COUNT } StadiumInstall.status = status -- The shiny variant rides beside its species as NNNs.dsm. -- -- A separate FILE rather than a second block inside NNN.dsm, and that is a -- deliberate trade. A second block would mean a new magic (DSM4), the same -- change mirrored into tools/stadium_pack.py, a regenerated oracle and a -- re-run of the 34MB byte diff -- the project's central safety net disturbed -- for a feature that does not need the format to move at all. As its own -- file it is the SAME DSM3 a normal pack is, written by the same writer and -- read by the same reader, and the 151 normal packs stay byte-identical. -- -- A species with no shiny variant simply has no NNNs.dsm, and StadiumPack -- falls back to the normal model. That is also what a half-finished install -- looks like, which is the behaviour we want from one. local function writePack(species, bytes, shinyBytes) local f = fs() if not f then return false, "no filesystem" end local ok, err = f.write(("%s/%03d.dsm"):format(StadiumInstall.DIR, species), bytes) if not ok then return false, tostring(err) end if shinyBytes then -- A failed shiny write is not a failed install: the species still has -- its model. Left unwritten, the runtime shows the normal one. local sok, serr = f.write( ("%s/%03ds.dsm"):format(StadiumInstall.DIR, species), shinyBytes) if not sok and V.mod and V.mod.log then V.mod.log.warn("shiny pack %03d not written: %s", species, tostring(serr)) end end return true end -- Open the ROM found in `baseroms/` and start a stepped build. Returns false -- plus a reason when there is nothing to build from. function StadiumInstall.begin() local f = fs() if not f then return false, "no filesystem" end local path = StadiumInstall.romPath() if not path then return false, "no ROM in " .. StadiumInstall.ROM_DIR end local okRead, bytes = pcall(f.read, path) if not (okRead and type(bytes) == "string") then return false, "could not read " .. path end return StadiumInstall.beginFrom(bytes, path) end -- The same, from bytes somebody else has already got hold of -- which is the -- IMPORTED path (StadiumRomPick), where the file is at an absolute location -- love.filesystem cannot see and was read with io.open. -- -- The two entry points share everything from here down on purpose: an -- imported cartridge and a dropped one produce the same 151 files, the same -- marker and the same md5, so there is exactly one build in this mod and no -- second one to keep in step. -- -- `label` is only ever used to say WHICH file a complaint is about. function StadiumInstall.beginFrom(bytes, label) local f = fs() if not f then return false, "no filesystem" end if type(bytes) ~= "string" or #bytes == 0 then return false, "empty file" end local StadiumRom = V.require("StadiumRom") local StadiumBuild = V.require("StadiumBuild") local rom, err = StadiumRom.open(bytes) if not rom then return false, tostring(err) end status.wrongVersion = false if not rom:isExpectedUS() then -- Built anyway rather than refused: a dump can differ from the reference -- for reasons that do not move a single model offset (a byte-order -- variant already normalised on load, a trimmed overdump). But every -- offset in this reader was measured against US 1.0 and nothing else is -- promised, so it is said loudly, with the md5 that IS expected so the -- player can check their own file against it. status.wrongVersion = true V.mod.log:warn("stadium: %s is md5 %s -- the model offsets are keyed to " .. "Pokemon Stadium (US) 1.0, which is md5 %s. Building " .. "anyway, but the models may be wrong or fail to build.", tostring(label or "the ROM"), tostring(rom:md5()), tostring(StadiumRom.US_MD5)) end -- ------- refuse a ROM with no models in it, BEFORE anything is written -- -- A file picker invites the wrong file -- most obviously the Game Boy -- cartridge the player already imported once -- and the reader's answer to -- one is a model count of zero. That has to be caught HERE rather than -- allowed to become an empty build, because an empty build is -- indistinguishable from a finished one further down: `job.total` is -- clamped to the count, `step` completes on the first call with nothing -- attempted and therefore nothing FAILED, and the marker gets written -- saying `DSM3 0`. -- -- On a fresh machine that is merely a lie on the loading screen -- READY, -- with no models. On one that already HAD them it is worse: the marker is -- the only thing that makes 151 files on disk count as installed, so -- overwriting it with a zero uninstalls a good set and the STADIUM rungs -- vanish off the row. Nothing below this line runs for a file that cannot -- possibly produce a build. local models = rom:modelCount() if not (models and models >= StadiumInstall.COUNT) then return false, "needs Pokemon Stadium US 1.0" end pcall(f.createDirectory, StadiumInstall.DIR) job = StadiumBuild.job(rom, writePack, StadiumInstall.COUNT) job.md5 = rom:md5() status.state = "building" status.done = 0 status.total = job.total status.error = nil return true end -- One species. Returns true while there is more to do. function StadiumInstall.step() if not job then return false end local more = job:step() status.done = job.done status.species = job.species if job.error then status.state = "failed" status.error = job.error job = nil return false end if not more then local f = fs() -- `job.total > 0` as well as "nothing failed", because a job with nothing -- IN it satisfies the second on its own -- and the marker this writes is -- what makes a set count as installed, so it must never be written for a -- build that did not happen. beginFrom refuses such a ROM outright; this -- is the same rule stated where the consequence is. local wrote = #job.failed == 0 and job.total > 0 if wrote and f then pcall(f.write, StadiumInstall.MARKER, ("%s %d %s %d\n"):format(StadiumInstall.FORMAT, job.total, tostring(job.md5 or ""), StadiumInstall.REV)) readyCache = nil StadiumPack.forget() end if not wrote then status.state = "failed" -- EVERY species failing is not a bad build, it is the wrong file: the -- offsets the reader walks are Pokemon Stadium's, so a different game -- -- or the Game Boy cartridge the player already imported once, which -- is the mistake a file picker invites -- misses on all 151 rather than -- on a few. Worth telling apart, because "0 of 151 models were built" -- reads as a broken mod and this reads as a wrong click. if #job.failed >= job.total then status.error = "needs Pokemon Stadium US 1.0" else status.error = ("%d of %d models could not be built") :format(#job.failed, job.total) end else status.state = "done" end job = nil return false end return true end function StadiumInstall.cancel() job = nil status.state = "idle" end return StadiumInstall