diff --git a/.gitignore b/.gitignore index c10636b..68bd0ab 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,42 @@ __pycache__/ # agent worktrees and local scratch .claude/ + +# ------- Pokemon Stadium data +# +# NONE OF THIS SHIPS, and none of it is in the repository. The battle models +# are Pokemon Stadium's own data: what the mod carries is the READER for them +# (lib/StadiumRom, StadiumFragment, StadiumFx, StadiumBuild) and the player +# supplies the cartridge, exactly as this engine already asks them to supply +# the Game Boy ROM it is a recompilation of. +# +# So the ROM itself, everything model_extract/pipeline extracts out of it, and +# the packs tools/stadium_pack.py builds from those are all ignored. What IS +# tracked is the pipeline, the notes, and the two READMEs that say where to +# put a ROM. +# +# At runtime the packs are built on the player's own machine, on first run, +# into the save directory -- never into the mod folder (see StadiumInstall). + +# the cartridge, wherever it is dropped, and the checksum note that comes +# with one. The Zone.Identifier pattern has no colon in it on purpose: it is +# an NTFS alternate data stream, and the separator reaches git as U+F03A +# rather than as ':' -- so matching on the suffix alone is what actually works +*.z64 +*.n64 +*.v64 +*Zone.Identifier +model_extract/baseroms/**/checksum.md5 + +# everything the pipeline extracts from it +model_extract/glb/ +model_extract/js/ +model_extract/textures/ +model_extract/manifest.json +model_extract/moves.json +model_extract/viewer.html + +# and the packed models built from those -- the local oracle the Lua +# extractor is diffed against (tests/stadium_extract_test.lua), rebuilt with +# tools/stadium_pack.py whenever it is wanted +assets/stadium/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 242770b..6edd0ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,547 @@ # Changelog +## 1.6.0 + +### Added + +- **STADIUM battles, and a disc stage: three new rungs on the 3D-BTL row.** + The row that used to read ON / OFF now reads **2D-3D A / 2D-3D B / + STADIUM A / STADIUM B / OFF**, which is two independent choices laid out + as one ladder -- WHAT is standing there, and WHERE: + + | | on the MAP | on two DISCS | + | ---------- | ----------- | ------------ | + | **pics** | 2D-3D A | 2D-3D B | + | **models** | STADIUM A | STADIUM B | + + 2D-3D A is what ON always was -- the fight staged on the map with the Game + Boy's own pics stood up on their tiles as quads. The STADIUM rungs keep + the whole staging and replace those quads with the **Pokemon Stadium + battle models**: all 151 species, skinned, lit and animated, playing the + animation the move being used actually calls for. + + **A** stands the fight on the map, in the world's own light and weather. + **B** stands it on two discs against the sky and draws no map at all -- + the Game Boy's own framing, staged rather than found. + + All four combinations are reachable rather than only the diagonal. The + discs do not know what is on them -- they are two platforms at two cells, + drawn off the arena alone -- so **2D-3D B** costs one value on the ladder + and gives the disc framing to a player who has no Pokemon Stadium ROM and + would rather not go and find one. It needs nothing the base game did not + ship: the stage is generated in Lua and the Pokemon on it are the game's + own art. + + B exists because the map does not always cooperate. Half of Kanto's + interiors are furniture, a cave floor can be nothing but two-cell + corridors, and some maps have nowhere a fight can be SEEN from a low + camera and are declined outright -- which drops the player back to the + flat battle screen with no warning. A carried stage has none of those + problems: it works on every map, at every step, and the framing is the + same every time. What it gives up is the thing A is for, which is + fighting somewhere real. + + It is abstracted from the GROUND, not from the world. The sky behind the + discs is the hour's own -- gold at dusk, navy at midnight -- and a fight + in a cave or a shop is under that place's void and its own flat light, so + walking into Mt. Moon at night and starting a battle looks like Mt. Moon + at night. + + The discs themselves are flat: one quad apiece, wearing a painted circle + that fades out at its rim, sized to the Pokemon standing on it. The fade + is an ordered dither baked into the texture's alpha rather than a smooth + ramp -- the scene shader discards under half alpha outright, so a ramp + would come out as a hard-edged circle -- which is the same trick the sky's + own bands use and reads as intended on a mode built out of visible texels. + + Nothing else about the fight moves. The arena is picked the same way, the + camera is solved the same way, and the HUDs, the frosted text box, the + move animations and the depth of field are all exactly what 2D-3D draws + -- because every one of those is hung off the arena's CELLS rather than + off the pics. Swapping what stands on a cell changes nothing about where + the cell projects to, which is why this is a rung on the mode rather than + a second mode. + + The animations are driven from the fight itself: a move plays the animation + that species' own battle table names for that move (the Stadium ROM's + per-species move table, packed into the assets, keyed by the same Gen 1 + move id the engine's move defs already carry -- so DIG really does put + Diglett into the ground), fainting plays the faint and holds on its last + frame, and a send-out grows the Pokemon out of the ball as it opens and + plays the entrance with it. Between all of that, the standby loop. The eyes blink and go + dizzy on their own counter, which is a texture animation glTF has no + channel for and the pack carries anyway. Charmander's tail flame and + Weezing's gas are there too, drawn additively over the body. + + The models go through the mod's OWN vertex format and shader rather than + a path of their own, which is what earns them everything the rest of the + diorama has: the depth buffer decides what is in front of what, the sun + pass throws a shadow of the actual pose, the hour's tint lands on them, + the hit flash flattens them and the tilt-shift and depth of field see + them as part of the picture. Skinning is done on the CPU -- these are + 674-vertex models and exactly two are ever on screen -- and once per + frame, so the sun, the camera and both VR eyes draw the same posed mesh. + + It declines per POKEMON rather than per battle: a species whose pack is + missing, a substitute doll, or the trainer's own pic before the send-out + all fall back to the flat card, on that side alone, with the other side + keeping its model. + + Stored as new values on the same key, with 2D-3D A still first -- so a + save written before this update reads back as exactly the mode it was + written for. + +- **The models are built on your machine, from your own cartridge.** The + mod ships no Pokemon Stadium data and cannot: it is that game's. What it + ships is the READER. + + **Press STADIUM ROM on the OPTIONS menu and pick the file.** The row opens + the host's own file dialog -- osascript on macOS, PowerShell's + OpenFileDialog on Windows, zenity then kdialog on Linux, which are the same + four the engine's own Game Boy importer uses -- and the models are built + from whatever comes back. `.z64`, `.n64` and `.v64` all work; the byte + order is detected. The row reads IMPORT before and READY after, and + pressing it again imports a different cartridge. + + The ROM is **not kept**: it is read, built from, and forgotten. A Stadium + cartridge is 32 MB and the models built out of it are 34, so keeping both + would double the cost of the feature for a file with no further use -- the + marker still records its md5, so a swapped cartridge is noticed. + + The wrong file is refused with a reason on the loading screen rather than + half-built, and refused BEFORE anything is written -- which matters more + than it sounds, because the marker is the only thing that makes 151 files + on disk count as installed, so a refusal that wrote one anyway would + uninstall a working set. + + The original route still works everywhere, and is the answer on the + platforms with no dialog (Android; a Linux install with neither zenity nor + kdialog): drop a Pokemon Stadium (US) ROM into a `baseroms/` folder beside + the game, straight in it rather than in a revision subfolder under it, and + the first time it runs + the 151 battle models are built out of it on a loading screen, in about ten + seconds, one species a frame. The screen says what it is doing in those + words -- **ONE-TIME EXTRACTION OF STADIUM ASSETS** -- carries a progress bar + filled by species written rather than by elapsed time, and names the + Pokemon it is on, which is the difference between a bar that is trusted and + one that is suspected of having hung. After that they sit in + the save directory and are read like any other asset. Until then the two + STADIUM rungs simply are not on the row: they are skipped rather than + shown and refused, because a setting that can be selected and then does + nothing is indistinguishable from a broken mod. + + This is the same arrangement the engine already has for the Game Boy ROM + it is a recompilation of, and it is why nothing ROM-derived is in this + repository. + + Doing it needed the whole extraction pipeline in Lua: the archive and its + Yay0 streams (`StadiumRom`), a geo-layout walk and an F3DEX2 interpreter + with six texture codecs and a bit-packed animation sampler + (`StadiumFragment`), the generated flame and gas stand-ins (`StadiumFx`), + and the bind-pose measurement and packer (`StadiumBuild`). + + **Pure Lua, and deliberately so: it runs anywhere LOVE does, phones + included.** No FFI, no native helper, no second process, nothing outside + the standard library and stock LOVE calls. On desktop it is about seven + seconds and peaks at 68 MB of Lua heap, 32 MB of which is the cartridge + itself; the working set does not grow across the run. + `tests/stadium_budget_test.lua` measures both and fails if either stops + being bounded, because a transient peak a desktop shrugs off is what gets + a process killed on a phone. On Android the save directory is the app's + external-files folder, so `baseroms/` there is reachable over USB or a + file manager without root. + +- **Acknowledgement for [pret/pokestadium](https://github.com/pret/pokestadium)**, + in README.md, model_extract/README.md and mod.card. The STADIUM extractor is + original code, but the decompilation is what it was written against -- the + bone matrix chain and the fact that scale is kept out of it, the rotation + basis, the animation and texture-animation samplers, the battle context + slots and the move-id constants all came from reading that project. None of + its code or data is vendored here or needed to run this, and the credit says + so as plainly as it says what was owed. + +- `tools/stadium_pack.py` now reads the ROM directly rather than a + pre-extracted tree, which makes it the ORACLE the Lua port is verified + against: `tests/stadium_extract_test.lua` runs both over the same + cartridge and requires all 151 packed files to come out **byte for byte + identical**. That is the only honest test of a port of that much numeric + code -- every rounding mode, iteration order and off-by-one shows up as a + differing byte, and there are thirty-four megabytes of them. + +- `ModSetting:setValue`, which sets a setting by its stored value rather + than by its place on the ladder, and `ModSetting:setGate`, which lets a + rung exist only when there is something behind it. 3D-BTL grew two rungs + in the middle of itself, and every caller that had counted to two would + otherwise have quietly meant something else afterwards. + +- `tests/stadium_shots.lua`, a shot driver for both rungs, with a control + mode and a pinned clock so two runs of it can be compared. + +- **`tests/stadium_anim_qa.lua`: every Pokemon, every animation, every + frame.** A battle asks a species for one of its animations and then poses, + skins, re-textures and draws it sixty times a second, and nothing exercised + that chain -- the pack probe reads the format and walks a bind pose, the + shot drivers show one species in one animation at a time. So the failures + only some species have had no way of being found except by a player calling + that Pokemon out, which is exactly how the eviction crash above was found. + + This is that sweep, headless: the real StadiumPack, StadiumRig and + StadiumMon over stubs for the three things a graphics context provides, and + the stubs are not lenient -- a released object throws with LOVE's own + message, because a stub that quietly accepted one would hide the class of + bug the sweep exists to find. 151 species, 403,716 posed frames, 25 + seconds. It also drives the state machine over all 165 move slots and + reproduces the cache eviction directly, which no amount of playing one + species' animations can reach. + + What it found, and what happened to each: + + | finding | count | outcome | + | --- | --- | --- | + | pack cache evicted a model still on the field | 2 | **fixed** -- see above | + | animation walks the Pokemon off its tile | 65 entrances, 36 hits, 35 faints | **fixed** -- see above | + | part has no texture | 104,728 | **not a defect.** 39 primitives across 37 species, 1.6% of the set's vertices, every one with all-zero UVs: they are flat-shaded geometry in the original. The pack stores texture indices one-based, so the packer's `0xFFFF` "untextured" sentinel arrives as 65,536 and resolves to nothing, which is correct. Counted rather than reported now | + | pose flies apart | 250 | **understood, left alone.** Two species in one animation each: Farfetch'd's entrance at 6.0x its bind height and Dewgong's at 6.1x, both just over the threshold, and both because the measurement is a bounding box that includes an authored trail -- Farfetch'd's is a dedicated 30-vertex primitive on a five-bone chain. The bodies are intact and, since the anchor, in frame | + | NaN or infinite vertex, rig would not build, loopStart out of range, missing aux animation, track indexed off its end, move slot with no animation | 0 | none | + + The three species the packer already declines (Exeggutor, Tangela, Magmar) + are skipped rather than swept: they are never posed in a game, and sweeping + them anyway produced 356 of the 362 original "flies apart" findings. + +### Fixed + +- **Idle animations snapped, and then ran at half the frame rate.** Two + bugs with the same cause, fixed in opposite directions. + + The animation streams are not keyframes: they carry one value per frame at + 30 Hz and the game steps them. Blended naively against a 60 Hz camera + that produced the reported glitch -- Rattata's idle snapping almost upside + down for a frame, Charmander's arms turning inside out for a few -- + because rotations here are **Euler triples**, and two triples can describe + nearly the same orientation while being nowhere near each other component + by component. `(0, 20976, 32736)` and `(0, -19936, -5904)` are a real + consecutive pair out of the set. Walking from one to the other passes + through orientations that are nothing like either end. + + Dropping the blend fixed that and cost the smoothness: every pose then + held for two frames, and a set of models moving at half the rate of + everything around them reads as a stutter. So the blend is back, and the + snaps are **detected** rather than smoothed. A bone whose rotation moves + more than a quarter turn inside one 30 Hz frame is not being animated, it + is being re-expressed, and it holds its frame instead of blending -- all + three components together, because they are one rotation. The same guard + covers a translation that teleports more than half the Pokemon's own + height in a frame. Angles blend the short way round the +-pi seam, and + texture animations still step whole frames, because an eye is open or shut + and there is no halfway swap to draw. + + Measured, not eyeballed: walking every species' standby loop in quarter + frames and differencing each bone's world origin against itself, the + number of species that jump more than a quarter of their own height in a + quarter frame goes **42 -> 11 -> 3**: naive blend, stepped, guarded blend. + The three that remain (Pidgeot, Dodrio, Grimer) have erratic rotation data + at source and are held rather than smoothed, which is exactly the guard + doing its job. + +- **Exeggutor, Tangela and Magmar are drawn as battle sprites instead of + models.** Those three come out of the ROM with standby loops that throw + bones hundreds of units off the body -- the game's own index arithmetic + evidently reads their channel streams differently from the way this does. + They used to stand still in their **bind pose** instead, on the reasoning + that a Pokemon looking like itself beats one coming apart. + + It does, but only just: a bind pose is a rigging pose, not a portrait, and + three species holding a T-pose among a hundred and forty-eight that + breathe read as broken rather than as still. So they now decline the model + outright and the Game Boy's own battle pic stands on the tile instead -- + the same per-Pokemon fallback a species with no pack at all already took, + in the same arena, under the same light. + + Keyed on the packer's own measurement rather than on a list of dex + numbers, so a re-extraction that fixes those streams -- or breaks a fourth + species -- moves this with it. + +- **The eyes blinked several times a second.** A texture animation was + running on a clock of its own and wrapping on its own stream's length. + Rattata's standby loop is forty frames and its blink is five -- `6 8 7 8 + 6`, open through shut and back -- so that played it six times a second, + and every species with a short blink twitched the same way. + + Two things were wrong, and the data says so plainly: 507 of the 691 + animation/texture-animation pairs in the set are exactly the same length + as each other, which is what one shared frame counter looks like from the + outside. So the texture animation now rides the SKELETAL animation's own + frame, and it HOLDS its last entry past the end of its stream rather than + looping -- which is what the game's own sampler does (`func_80017540`). + Rattata now blinks once at the top of each idle loop and keeps its eyes + open for the other thirty-five frames. + + The frame is not recomputed for the textures; `pose` stashes the one it + resolved and `textures` reads it, so the two cannot drift apart. + +- **The faint animation no longer plays while the HP bar is still + draining.** `onFaint` runs the instant HP reaches zero, but the engine + queues the visible collapse behind the move animation and the bar drain + -- and that drain takes real time, some two and a half seconds on a + full-health Pokemon. The model was therefore lying down while its own + health went on emptying above it. + + The request is now recorded when HP hits zero and played when the bar + reaches zero, off the engine's own `shownHP` -- the same number the bar is + drawn from, so the two cannot drift. A faint that stops being owed in the + meantime (a revive, a battler replaced under us) is dropped rather than + fired late at whoever is standing there. Measured rather than eyeballed: + the shot driver's `DS_FAINT` case prints the frame the bar empties against + the frame the animation starts, and they are the same frame. + +- **Being hit played the ATTACK animation.** The context slot the mod called + `hit` is not a damage reaction: read against the move table, Bulbasaur's is a + 95-frame animation that 66 of its moves play, and Pidgey's is 138 frames -- + four and a half seconds -- shared by 111 of its moves. That is the species' + DEFAULT ATTACK, which is why taking damage looked exactly like swinging: it + was the swing. Slots 173, 178, 179, 180 and 181 all point at the same one. + + Nor is a reaction hiding elsewhere. Exactly one animation per species is + claimed by no slot and no move, and it is the same length as that species' + idle for essentially all of them -- 48/48, 56/56, 60/60, 84/84 -- so it is a + second standby loop, not a recoil. **This set has no damage reaction in it.** + + So damage now plays nothing and the Pokemon carries on with what it was + doing, which is what the engine already communicates through its own screen + flash, pic blink and HP drain. The slot is renamed `attack_default` + throughout (a label on a position -- the file format is the ORDER, so no + bytes changed), and the generic swing a move with no table entry falls back + to now uses it rather than resolving to the standby loop. + +- **The battle menu no longer changes colour with the scenery.** There was a + pass that measured each frosted panel's average brightness and flipped the + glyphs to white over a dark one, with hysteresis so a drifting camera could + not strobe them. It worked, and it was still wrong: the battle menu is the + part of the frame the player reads constantly, and having its colour depend + on where the camera happens to point makes it unreliable furniture. Gen 1's + battle ink is black, so it is black -- on a cave floor as much as on a + meadow -- and the panel's tint, which always pushes toward white, is what + earns it its contrast. Removing it also took out a one-pixel GPU readback + that ran several times a second to answer a question nothing asks any more. + +- **The required ROM is now named everywhere: Pokemon Stadium (US) 1.0.** It + always was the only one that works -- every offset in the reader was + measured against that cartridge -- but the docs and the file picker just + said "Pokemon Stadium (US)", which is three different ROMs. The picker's + title, the OPTIONS help, the README, mod.card and the manifest all name the + revision now, the README carries the reference md5 + (`ed1378bc12115f71209a77844965ba50`) so a player can check their own file, + and a build from anything else says so on the loading screen as well as the + console rather than quietly producing wrong models. + +- **The extraction screen just says STADIUM EXTRACTION now**, with the + progress bar and the species name under it; the "this runs once" line is + gone. + +- **Pokemon now grow out of the ball, instead of appearing beside it.** + Measured, the send-out is: the ball is thrown, the POOF animation runs for + 27 frames, and `startGrowIn` fires on the frame AFTER it ends. So the model + did not begin to exist until the ball had finished opening -- the ball came + apart, and then a Pokemon was switched on next to it. + + The engine's own ramp is the Game Boy's three steps (0, 3/7, 5/7, full) + across the twelve frames after that, which on a 56-pixel sprite is a chunky + pop and on a smooth 3D model is just a pop. + + The model now runs its own ramp, started when the POOF BEGINS and + continuous: it grows out of nothing while the ball is coming apart and + reaches full size exactly as the engine's own grow finishes -- 39 frames + end to end, which is the poof's 27 plus the engine's 12. Smoothstep rather + than linear or ease-out, because the ball is still opening through the first + half and a curve that was already near full size by then would have the + Pokemon standing about waiting for it. The entrance animation starts with + the grow, so the arrival is one performance rather than a grow followed by + a flourish. + + Its own ramp OWNS the arrival once it starts: falling back to the engine's + afterwards shrank the Pokemon from 0.96 back to 0.71 and then snapped it to + full, because the two finish a few frames apart -- a visible hitch at the + end of the one animation that exists to not have one. + +- **The first Pokemon of a battle arrived, left, and arrived again.** Every + guard deciding whether the player's Pokemon is on the field is a field the + engine sets once the battle is RUNNING, and during the opening none of them + is set yet: `showPlayerBack` is still nil (BattleState assigns it further + in), `playerBackPic` is nil with it, and `sendingOut` does not go true until + the ball is thrown. So the whole intro read as "this Pokemon is standing on + the field" -- two and a half seconds of it, on its tile, playing its standby + loop, before the trainer sprite it is meant to be hiding behind had even + appeared. It then vanished when that sprite arrived and came back with its + entrance when the ball opened. A switch has no intro, which is why a switch + always looked right and was the thing worth comparing against. The player's + side is now simply not on the field during the intro phase. + +- **The anchor made birds shake, and the shake read as fast flapping.** The + body estimate it corrects toward was the MEDIAN bone origin -- robust to a + few bones flung out, which is what it was chosen for, and wrong in a way + that only shows on a flapping model: a median is a RANK, and on a bird most + of the skeleton is wing, so which bone sits at the middle of the sorted list + swaps between the up cluster and the down one every beat. Measured, the + estimate moved a tenth of a body-height between adjacent half-frames on + Pidgey and three whole body-heights on Pidgeot, and the anchor turned that + into a translation of the entire Pokemon -- the body counter-shaking against + its own wings, which reads as flapping at twice the real speed. + + The centre is now the bone origins averaged and **weighted by how many + vertices each bone moves**. The weights are a property of the mesh, computed + once, so there is no rank to flip -- and a bone with little geometry barely + counts, which is the robustness the median was for in the first place + (Farfetch'd's trail is 30 vertices on five bones). On top of that the + correction is low-passed, so what survives is where the Pokemon has drifted + to and never how it is shaking on the way. Pidgey's shake goes from 0.24 to + **0.10 pixels a frame** on a fourteen-pixel model, and travel correction + improved with it. + + Two species -- Pidgeot and Dodrio -- have standby loops with genuinely junk + rotation frames, which move the estimate three and two body-heights in a + single frame against a fifth of a body-height for the fastest real motion in + the set. No filter separates those: rate-limiting the correction bounded the + shake but put 33 of the 148 entrances back outside the frame, and + rate-limiting the measurement could not tell a spike from an excursion + because they are only a factor of fifteen apart. So each species' own + standby loop is walked once, at rig construction, and one whose estimate is + that unsteady is **not anchored at all**: it travels as far as its animation + says and does not vibrate, which is exactly how it behaved before the anchor + existed. One species trading a framing problem for no problem beats 147 + trading a solved framing problem for a shake. + +- **A Pokemon calling out a fifth species killed every model in the fight.** + Reported as `Cannot use object after it has been released` out of + `Voxel3D.draw`, after which nothing 3D drew for the rest of the battle. + + The pack cache holds four models and evicts the least recently *loaded* -- + and `load` only runs when a side's species CHANGES, so a Pokemon that has + been standing there for a few turns is the oldest entry in the cache. Send + out a fifth species and it was evicted mid-fight and **its textures + released**, while it was still being drawn sixty times a second. + + Two things were wrong. The eviction released each texture but left the dead + object in its slot -- and a released Image is still a truthy value, so the + next `image()` handed the corpse straight back out to `mesh:setTexture`. + Slots are now cleared, so an evicted model simply decodes its textures + again. And the mode now says, every frame, which two species are actually + standing there (`StadiumPack.keep`), so the two in use are always the two + most recent and cannot reach the front of the queue at all. + +- **And a model that does fail now fails gracefully.** Both draws were a bare + loop inside the caller's single pcall, so a throw on the first side skipped + the second -- one broken Pokemon took its opponent off the screen with it + -- and nothing recorded that it had happened, so the same throw came back + every frame forever. Each side is now drawn, cast and posed inside its own + guard, and a side that throws is RETIRED: its rig is released and + OverworldBattle renders its flat battle pic from the next frame on, which + is the fallback a species with no pack has always had. The fight carries on + with a flat Pokemon instead of a missing one, and its opponent is + untouched. + +- **Half the set's animations walked the Pokemon out of the shot.** Found by + the sweep below, not by a bug report, and it is the biggest of them: 65 of + the 148 send-out entrances carry the body more than its own height off the + spot it started on -- Dewgong's reaches seven and a half, and its faint + nearly ten. Every one returns to exactly where it began, because Pokemon + Stadium framed each Pokemon with a camera of its OWN that followed the + performance around a stage. This mode has one camera, solved to hold two + fixed map cells, so a Pokemon that travels seven body-heights is simply + gone: sending out a Farfetch'd left an empty tile for three and a half + seconds while its animation played somewhere off to the left of the frame. + + `StadiumRig.anchor` now takes the excess back out -- the pose is measured + against where the bind pose put the body, and whatever has carried it + further than `StadiumMon.TRAVEL` (three quarters of a body-height, which is + what the frame holds) is subtracted from every bone. The EXCESS only: the + 83 species that never reach the limit are bit-for-bit what they were, and a + lunge, a hop or a collapse still reads as big and still comes back to the + tile it left. The centre is the median bone origin rather than the mean or + the root, so Farfetch'd's five-bone trail streaking three thousand units + out cannot drag the bird with it. + +- **FLY and DIG now take the model off the field.** The charging turn of a + two-turn move puts the Pokemon out of reach, and the engine says so through + `picFx[battler].hidden` -- FLY runs `SE_SLIDE_MON_OFF` and DIG + `SE_SLIDE_MON_DOWN`, each a 19-24 frame slide that ends by setting that + flag, and the release turn puts the pic back. The model was reading + `fxHidden`, which is the damage BLINK and nothing else, so it stood on its + tile while the game insisted it was underground -- and insisted in the + strongest way it has, by making every attack aimed at it miss. + + It now reads the same field the pic does, which also covers every other + vanishing act on that seam: the user of Explosion, a Pokemon Teleported + away. Read as the engine's own answer rather than as a list of move ids, + so a mod that adds a third two-turn move gets it for free. + + It is deliberately NOT held to the end of its own animation the way a + collapse is. The Stadium animations are authored as the WHOLE move -- + Charizard's DIG is 3.83 seconds of burrow, emerge and strike, because + Stadium plays it in one turn -- so cutting at the engine's own hide shows + the burrowing and holds the strike back for the turn it actually lands on. + Verified as a timeline (`DS_FLY`, and `DS_MOVE=DIG`): the pic hid at frame + 67, the model went with it, and both came back at 264. + +- **And the faint animation now gets to finish.** A fainted Pokemon left the + field when its PIC did, and the engine's pic slide is fourteen frames of a + 60 Hz clock -- `SlideDownFaintedMonPic`, seven rows two frames apart, under + a quarter of a second. The Stadium faint animations are nothing like that + short: the briefest in the set is 49 frames of a 30 Hz clock, the median is + 110 and the longest 230. Every model was therefore cut off inside the first + fifth of its own collapse -- the Pokemon began to fall and then vanished + mid-fall, which is worse than not animating at all. + + A model that is collapsing now stays until it has finished collapsing, and + the two timings are simply not tied to each other any more: how long a flat + pic takes to slide off the bottom of a 160x144 frame has nothing to say + about how long it takes a Gyarados to fall over. Bounded at both ends -- + it ends when the animation does, so nothing is left lying on the field for + the rest of the fight, and the side is reset outright the moment a + different battler stands in that slot, which is what stops the next + Pokemon out of the ball arriving face down (that one bites when a trainer + leads with two of the same species, where the dex number never changes and + nothing downstream would otherwise notice the swap). + + `DS_FAINT` now prints the span as well as the moment: 127 frames against + the pic's 14. + +- Two ordering bugs in the extraction pipeline that made its output + **non-reproducible**. The generated flame nodes were deduplicated through + a Python `set` of tuples containing strings, so their order moved with + `PYTHONHASHSEED` and Ponyta, Rapidash and Moltres got differently-seeded + flames on different runs of the same build; and texture registration + order came from iterating a `set` of ints, which is stable across runs + but is a CPython implementation detail rather than a fact about the data. + Both now go in order of appearance, which is the game's own order and the + one a Lua port can reproduce. + +### Changed + +- The packed format is now **DSM3**: textures are stored as raw RGBA rather + than PNG. An ImageData over those bytes costs nothing where a PNG costs a + decode on the frame a battle starts -- but the reason it was done is that + PNG means zlib, and Python's deflate and LOVE's need not agree byte for + byte, which would have made the extractor impossible to check against the + packer. Uncompressed pixels are the same pixels whoever wrote them. The + set is 34 MB rather than 24 MB, and it is generated locally rather than + shipped, so that is a trade worth making. + +### Known + +- Three species -- Exeggutor, Tangela and Magmar -- have standby loops that + are corrupt in the source extraction, and fall back to their battle + sprites on the STADIUM rungs (see above). 148 of the 151 have models. + +- Pidgeot, Dodrio and Grimer have a handful of erratic rotation frames in + their standby loops -- a few frames of junk at the top of the loop rather + than a corrupt stream. The blend guard holds those frames rather than + smoothing through them, so they step where the source steps, and Pidgeot + and Dodrio are additionally left unanchored because those frames move the + body estimate too far to measure against (see above). Not chased + further: the extraction is byte-identical to a reference pipeline whose + sampling was validated against the decompilation's own arithmetic, and + guessing at the format to fix three species risks the other 145. + ## 1.5.5 ### Added diff --git a/README.md b/README.md index 4b2b42b..addf73c 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ menu. | `5`, or the **V-GRID** options row | OFF / ON — a one-pixel wireframe on every voxel | | `6`, or the **T-SHIFT** options row | OFF → 1 → 2 → 3 → OFF (miniature blur) | | `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 — bend the world over the horizon | -| `8`, or the **3D-BTL** options row | ON / OFF — fight on the map instead of on a white field | +| `8`, or the **3D-BTL** options row | 2D-3D A / 2D-3D B / STADIUM A / STADIUM B / OFF — fight in 3D instead of on a white field. **A** stages it on the map, **B** on two discs against the sky; **2D-3D** uses the game's own battle pics and **STADIUM** the Pokémon Stadium battle models | | `9`, or the **WATER** options row | FULL / SKY / OFF — waves and reflections on water. **SKY** gives the surface its pixel-tall wave columns and puts the sky, the sun, the moon and the cast in them; **FULL** adds a screen-space ray march that also reflects the shoreline, the trees and the buildings standing behind it | | the **BACK SPRITES** options row | OFF / ON — keep your own Pokémon on the battle menu, seen from behind in its classic slot, instead of standing it on the map; the foe is still out there. Only on the menu while **3D-BTL** is on, because it decides nothing without it | | the **AA** options row | OFF / 2X / 4X — smooth the stair-stepped edges of the 3D world by rendering the diorama larger than the window and folding it back down. The ladder is samples per display pixel: 2X is a canvas root-two wider and taller, 4X one exactly twice the size. Every edge in the projected picture softens with the silhouettes — the tileset's own texels are quads in a perspective view and cross the pixel grid at the same arbitrary angles — so the diorama reads smoother rather than sharper. The most expensive row in the mod, so it is OFF by default and **FULL** leaves it alone | @@ -80,6 +80,109 @@ on the menu while the foe stands out on the map, and no angle holds a composition that is half frame and half world — so with it on, the shot holds the one the rig was solved for. +## STADIUM battles + +The **3D-BTL** row has five rungs, which are two choices — what is standing +there, and where: + +| rung | the fight | +| --- | --- | +| **2D-3D A** | staged on the map, with the Game Boy's own pics stood up on their tiles | +| **2D-3D B** | those same pics on two discs against the sky, with no map drawn | +| **STADIUM A** | staged on the map, with the Pokémon Stadium battle models | +| **STADIUM B** | those models on the discs | +| **OFF** | the engine's own battle screen | + +**A** is the map — real ground, in that place's own weather and light. **B** +is the carried stage, which works everywhere, including the caves and shop +floors that have nowhere to put a fight. Only the STADIUM rungs need a ROM; +**2D-3D B** is generated in Lua and uses the game's own art. + +Skinned and animated, playing the animation the move being used actually +calls for — the Stadium ROM's own per-species move table, so **DIG** really +does put Diglett into the ground. Fainting plays the faint and holds there, a +send-out grows the Pokémon out of the ball as it opens and plays the entrance, +and between all of that the standby loop runs. Eyes blink and go dizzy; +Charmander's tail flame and Weezing's gas are drawn over the body. + +Taking damage plays nothing, because the set has no damage reaction in it — +the slot that looked like one is each species' default attack, which is why +being hit used to look like swinging. The engine's own screen flash, pic blink +and HP drain are what say "that hurt". + +148 of the 151 have models. Exeggutor, Tangela and Magmar come out of the ROM +with corrupt standby loops and stand as their Game Boy battle sprites +instead, on their own tile, in the same arena — the same per-Pokémon fallback +a substitute doll and the pre-send-out trainer pic already take. + +**B is for the maps that cannot host a fight.** Half of Kanto's interiors are +furniture, a cave floor can be nothing but corridors, and a map where neither +Pokémon can be *seen* from a low camera is declined outright — which drops you +back to the flat battle screen. B carries its stage, so it works everywhere +and looks the same every time. It is abstracted from the ground, not from the +world: the sky behind the discs is the hour's own, and a fight in a cave is +under that cave's void and its own flat light. + +### Getting the models + +**They are not in this mod, and they cannot be** — they are Pokémon Stadium's +data. What ships is the reader; you supply the cartridge, exactly as this +engine already asks you to supply the Game Boy ROM it is a recompilation of. + +> **You must supply a Pokémon Stadium (US) 1.0 ROM.** Not Stadium 2, not +> another region, not a later revision. Every offset in the reader was +> measured against that one cartridge, and nothing else is promised: a +> different file is either refused outright or builds models that are subtly +> wrong. The mod checks, and says so — on the console, and on the loading +> screen itself if it built from something unexpected. +> +> The reference dump is **md5 `ed1378bc12115f71209a77844965ba50`**, 32 MB. +> The mod does not tell you where to get one, and none ships with it. + +1. Open **OPTIONS** and press the **STADIUM ROM** row. It opens your system's + file picker; choose your **Pokémon Stadium (US) 1.0** ROM. `.z64`, `.n64` + and `.v64` all work — the byte order is detected, and the wrong file is + refused with a reason rather than half-built. +2. The 151 models are built on a loading screen that says so and shows a + progress bar, in about ten seconds. The row then reads **READY**. + +The ROM itself is **not kept** — it is read, built from, and forgotten, so +the cartridge does not sit in your save directory alongside the models it +produced. Press the row again any time to import a different one. + +There is no picker on Android, or on a Linux install with neither `zenity` +nor `kdialog`. Those keep the original route, which still works everywhere: + +- Put the **US 1.0** ROM in a `baseroms/` folder beside the game — straight + in it, not in a subfolder — and start the game. +- In a packaged build (and on Android) `baseroms/` goes in the save + directory; the mod logs the exact path on startup when it cannot find one. + On Android that is the app's external-files folder, reachable over USB or + any file manager without root. + +Either way, the two STADIUM rungs appear on the 3D-BTL row when it's done. + +The built models live in the save directory, not in the mod folder, and are +rebuilt automatically if the format changes or the ROM does. Until they exist +the STADIUM rungs are simply not on the row — skipped rather than shown and +refused, because a setting you can select that then does nothing is worse than +one that is not there. + +**This works on mobile.** The extraction is pure Lua — no FFI, no native +helper, no second process — so it runs anywhere LÖVE does. It peaks at about +68 MB of Lua heap (32 MB of that the cartridge itself) with a working set that +does not grow across the run, and `tests/stadium_budget_test.lua` fails if +either stops being true. On Android the save directory is the app's +external-files folder, so `baseroms/` there is reachable over USB or a file +manager without root; the build is slower than a desktop's ~7 s but runs one +species a frame behind the progress bar either way. + +Developers can pre-build them with `tools/stadium_pack.py`, which reads the +same ROM through `model_extract/pipeline`. That path is also the *oracle*: +`tests/stadium_extract_test.lua` runs it and the in-game Lua extractor over +the same cartridge and requires all 151 packed files to come out byte for byte +identical. + ## VR The **VR** options row (OFF / ON, off by default) drives a PCVR headset @@ -128,4 +231,36 @@ geometry and shape profiles are derived from the tile and sprite data of the original game, as documented by the [pret/pokered](https://github.com/pret/pokered) disassembly. No ROM data, artwork or audio is included; the mod reads the assets the host -game already has. \ No newline at end of file +game already has. + +### Acknowledgements — pret/pokestadium + +The STADIUM battle models are read out of the player's own Pokémon Stadium +(US) 1.0 cartridge by original code in [`lib/`](lib) and +[`model_extract/`](model_extract). **That code exists because of +[pret/pokestadium](https://github.com/pret/pokestadium)**, the community +decompilation of that game, which is the reference this mod's reader was +written against. Specifically, it is where the following came from: + +- the bone matrix chain, and the fact that scale is kept *out* of it and + applied only at draw time (`func_800143C0`) — the single most important + thing to get right in the whole rig, and not guessable from the data +- the rotation basis and its row-vector `Rx·Ry·Rz` order + (`func_8000F730`, `src/F420.c`) +- the animation player's frame counter and loop-start behaviour + (`func_80016FBC`), and the texture-animation sampler that *clamps* past + the end of its stream rather than wrapping (`func_80017540`) — which is + the difference between a Pokémon blinking and twitching +- the battle system's per-species animation context slots and the routines + that select them (`func_8432B0A4`, `func_8430506C`, `func_84305A74`) +- the move-id constants the per-species move table is keyed by + +**No code, data or asset from that project is included in or redistributed +by this mod**, and none is needed to build or run it. What was taken is an +understanding of the file formats, re-expressed in this mod's own Lua and +Python. If you want to reuse anything from the decompilation itself, get it +from upstream and follow that project's own terms. + +No Pokémon Stadium ROM data ships here either. The models are built on the +player's own machine, from a cartridge they supply, into their own save +directory — see [Getting the models](#getting-the-models). \ No newline at end of file diff --git a/lib/BattleHud.lua b/lib/BattleHud.lua index 34432e7..776af0f 100644 --- a/lib/BattleHud.lua +++ b/lib/BattleHud.lua @@ -12,15 +12,17 @@ -- and an opaque slab in the corner of the frame is the white field back -- again by another name. -- --- And the text flips. A panel over a sunlit meadow is bright and wants black --- glyphs; the same panel over a cave floor or a dark roof is not, and wants --- white ones. So the panel's average brightness is measured and the glyphs --- follow it, with hysteresis so a slow camera drift across the threshold --- cannot strobe them. +-- The ink does NOT change. There was a pass here that measured each panel's +-- average brightness and flipped the glyphs to white over a dark one, with +-- hysteresis so a drifting camera could not strobe them. It worked, and it +-- was still wrong: the battle menu is the one part of the frame the player +-- reads constantly, and having its colour depend on what the camera happens +-- to be pointing at makes it an unreliable piece of furniture. Gen 1's +-- battle text is black, so it is black -- and the panel's tint is what +-- earns that its contrast, on a cave floor as much as on a meadow. -- --- The measurement is a one-pixel readback, which is a GPU stall, so it runs --- a few times a second rather than every frame. The camera drifts at about --- a pixel a second; brightness cannot outrun that. +-- Removing it also took out a one-pixel GPU readback that ran several times +-- a second purely to answer a question nothing asks any more. -- the mod namespace (see main.lua): V.require loads a sibling module local V = ... @@ -37,24 +39,13 @@ local BattleHud = {} BattleHud.FROST = 0.55 BattleHud.TINT = 0.26 --- The luminance the glyphs flip at, with a dead band so a drift across it --- settles rather than strobes. -BattleHud.DARK_ENTER = 0.44 -- below this, the panel is dark: white glyphs -BattleHud.DARK_LEAVE = 0.56 -- above this, back to black ones - --- Frames between brightness readbacks. -BattleHud.SAMPLE_EVERY = 12 - -- The frost buffer's height; width follows the source's aspect. Small on --- purpose: the downscale is most of the blur, and what is read back for the --- brightness is one pixel of it. +-- purpose: the downscale is most of the blur. BattleHud.FROST_H = 72 local frost, frostW, frostH = nil, 0, 0 local blurA, blurB = nil, nil -local probe = nil local frame = 0 -local luma = {} -- panel key -> { value, dark, at } local SHADER = [[ uniform vec2 dir; @@ -102,7 +93,6 @@ function BattleHud.build(src) frost = canvasOf(w, h) blurA = canvasOf(w, h) blurB = canvasOf(w, h) - probe = probe or canvasOf(1, 1) if not (frost and blurA and blurB) then frost, blurA, blurB, frostW, frostH = nil, nil, nil, 0, 0 return nil @@ -149,46 +139,6 @@ function BattleHud.frame() return frame end --- Average luminance of the frost under `key`'s rect, in frost-canvas pixels. --- --- Averaged by letting the GPU do it: the rect is drawn into a one-pixel --- canvas, which IS the mean, and that one pixel is read back. Cached for --- SAMPLE_EVERY frames because the readback synchronises the pipeline and --- nothing it measures moves faster than that. -local function sampleLuma(key, fx, fy, fw, fh) - local hit = luma[key] - if hit and (frame - hit.at) < BattleHud.SAMPLE_EVERY then return hit.value end - if not (frost and probe and frostW > 0) then return hit and hit.value end - if fw <= 0 or fh <= 0 then return hit and hit.value end - - local prevCanvas = love.graphics.getCanvas() - local prevBlend, prevAlpha = love.graphics.getBlendMode() - local value = hit and hit.value or 1 - local ok = pcall(function() - love.graphics.setCanvas(probe) - love.graphics.setBlendMode("replace", "premultiplied") - love.graphics.setColor(1, 1, 1, 1) - local quad = love.graphics.newQuad(fx, fy, fw, fh, frostW, frostH) - love.graphics.draw(frost, quad, 0, 0, 0, 1 / fw, 1 / fh) - love.graphics.setCanvas() - local data = probe:newImageData() - local r, g, b = data:getPixel(0, 0) - if data.release then pcall(data.release, data) end - value = 0.299 * r + 0.587 * g + 0.114 * b - end) - - if prevCanvas then - love.graphics.setCanvas(prevCanvas) - else - love.graphics.setCanvas() - end - love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha) - if not ok then return hit and hit.value end - - luma[key] = { value = value, at = frame } - return value -end - -- Map a GB-frame rect onto the frost canvas, given where the letterbox sits -- in the source the frost was built from. local function frostRect(rect, box) @@ -218,44 +168,14 @@ local function mapper(world) return world and frostRectWorld or frostRect end --- ------- the verdict --- --- ONE answer for the whole frame, not one per panel. Both HUDs draw in a --- single pass and there is only one glyph colour to be had out of it -- and --- a frame with a black-lettered HUD in one corner and a white-lettered one --- in the other would read as a bug rather than as adaptation. The DARKER --- panel decides, because it is the one that cannot afford to be wrong, and --- the tint below then commits both panels to that reading. -local wasDark = false - -function BattleHud.verdict(rects, box, world) - if not (frost and box and box.scale and box.scale > 0) then return false end - local toFrost = mapper(world) - local darkest = nil - for key, rect in pairs(rects) do - local fx, fy, fw, fh = toFrost(rect, box) - local v = sampleLuma(key, fx, fy, fw, fh) - if v and (not darkest or v < darkest) then darkest = v end - end - if not darkest then return wasDark end - -- hysteresis: it takes a clear move past the far threshold to flip back, - -- so a camera drifting across the boundary settles instead of strobing - if wasDark then - wasDark = darkest < BattleHud.DARK_LEAVE - else - wasDark = darkest < BattleHud.DARK_ENTER - end - return wasDark -end - -- Draw one HUD panel into the current target, in that target's own -- coordinates: GB ones for the 160x144 UI canvas, world pixels (world = true) -- for a panel laid straight onto the world image. -- --- The tint always pushes AWAY from the glyph colour that is about to be --- used, so the contrast is guaranteed rather than hoped for: a dark panel --- gets darker under white text, a bright one brighter under black text. -function BattleHud.panel(rect, box, dark, world) +-- The tint always pushes toward WHITE, away from the black ink that is about +-- to land on it, so the contrast is guaranteed rather than hoped for -- and +-- it is the whole of what makes a fixed ink colour workable over any ground. +function BattleHud.panel(rect, box, world) if not (frost and box and box.scale and box.scale > 0) then return false end local fx, fy, fw, fh = mapper(world)(rect, box) local ok = pcall(function() @@ -263,93 +183,13 @@ function BattleHud.panel(rect, box, dark, world) love.graphics.setColor(1, 1, 1, BattleHud.FROST) love.graphics.draw(frost, quad, rect[1], rect[2], 0, rect[3] / fw, rect[4] / fh) - local shade = dark and 0 or 1 - love.graphics.setColor(shade, shade, shade, BattleHud.TINT) + love.graphics.setColor(1, 1, 1, BattleHud.TINT) love.graphics.rectangle("fill", rect[1], rect[2], rect[3], rect[4]) love.graphics.setColor(1, 1, 1, 1) end) return ok end --- ------- flipping the glyphs --- --- Over a dark panel the HUD's black text has to go white, and it cannot be --- done by setting a draw colour: LOVE MULTIPLIES by it, and a black glyph --- times white is still black. The colour channel has to be REPLACED. --- --- So the HUD is drawn into a scratch layer and that layer is composited back --- through a shader that whitens whatever is nearly black and leaves the rest --- alone. "Nearly black" is the text, the tick marks and the bar's outline -- --- everything the HUD draws as ink -- while the HP bar's own greens and reds --- are well clear of the threshold and come through untouched. --- --- Composited back into whatever the caller had bound, which is what makes it --- work in both pipelines without knowing which one it is in: in the colorized --- one that target is the grayscale BG canvas, where white IS shade 0 and the --- zone pass then colours the flipped glyphs like every other lightest-shade --- surface; in the flat fallback it is the screen, where white is white. -local INK = 0.35 -- luminance at or under which a pixel counts as ink - -local FLIP = [[ - uniform float ink; - vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { - vec4 p = Texel(tex, tc); - float luma = dot(p.rgb, vec3(0.299, 0.587, 0.114)); - if (p.a > 0.0 && luma <= ink * p.a) p.rgb = vec3(p.a); - return p * color; - } -]] - -local flipShader = nil -local layer = nil - -local function getFlip() - if flipShader == nil then - local ok, sh = pcall(love.graphics.newShader, FLIP) - flipShader = (ok and sh) or false - end - return flipShader or nil -end - --- Whether the flip pass can run at all, for the shot driver's log. -function BattleHud.flipReady() - return getFlip() ~= nil -end - --- Run `fn` with its ink whitened. Falls back to running it plainly when the --- scratch layer or the shader is unavailable, so a driver that cannot do --- either gets the vanilla black HUD rather than no HUD. -function BattleHud.flipGlyphs(w, h, fn) - local sh = getFlip() - if not sh then return fn() end - if not layer or layer:getWidth() ~= w or layer:getHeight() ~= h then - layer = canvasOf(w, h, "nearest") - if not layer then return fn() end - end - - local prevCanvas = love.graphics.getCanvas() - local prevBlend, prevAlpha = love.graphics.getBlendMode() - local ok, err = pcall(function() - love.graphics.setCanvas(layer) - love.graphics.clear(0, 0, 0, 0) - love.graphics.setBlendMode("alpha") - fn() - end) - if prevCanvas then - love.graphics.setCanvas(prevCanvas) - else - love.graphics.setCanvas() - end - love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha) - if not ok then error(err, 0) end - - love.graphics.setShader(sh) - pcall(sh.send, sh, "ink", INK) - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(layer, 0, 0) - love.graphics.setShader() -end - -- ------- the whole HUD layer as a texture -- -- The two blocks do not sit in the same place any more: each is snapped to its @@ -358,13 +198,9 @@ end -- places at once, so the layer is rendered ONCE into a GB-sized canvas and -- each block is then blitted out of it as a quad. -- --- `dark` runs the ink through the same flip the in-frame HUD uses, here baked --- into the texture rather than composited into the caller's target -- the world --- image the quads land on is a colour canvas, and a flip pass over it would --- whiten the terrain behind the glyphs along with them. local hudLayer = nil -function BattleHud.layerTexture(w, h, dark, fn) +function BattleHud.layerTexture(w, h, fn) if not hudLayer or hudLayer:getWidth() ~= w or hudLayer:getHeight() ~= h then hudLayer = canvasOf(w, h, "nearest") if not hudLayer then return nil end @@ -377,9 +213,7 @@ function BattleHud.layerTexture(w, h, dark, fn) g.clear(0, 0, 0, 0) g.setBlendMode("alpha") g.setColor(1, 1, 1, 1) - -- flipGlyphs renders fn into its own scratch layer and composites the - -- whitened result into whatever is bound, which is this canvas - if dark then BattleHud.flipGlyphs(w, h, fn) else fn() end + fn() end) if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end g.setBlendMode(prevBlend or "alpha", prevAlpha) @@ -388,21 +222,10 @@ function BattleHud.layerTexture(w, h, dark, fn) return hudLayer end --- The last luminance measured, for the shot driver's log. -function BattleHud.lastLuma() - local best = nil - for _, hit in pairs(luma) do - if not best or hit.value < best then best = hit.value end - end - return best -end - function BattleHud.invalidate() - frost, blurA, blurB, probe = nil, nil, nil, nil + frost, blurA, blurB = nil, nil, nil frostW, frostH = 0, 0 - luma = {} - wasDark = false - layer, hudLayer = nil, nil + hudLayer = nil end return BattleHud diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index 66f13ca..008d84b 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -315,12 +315,24 @@ end local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, atlasFor, cards, token, host, neighbors, - water, nbWater) + water, nbWater, groundY) if not ShadowMap.available() then return end local sig = shadowSignature(state, arena, terrain, nbMesh, token) if not ShadowMap.stale(sig) then return end if not ShadowMap.begin(cx, cy, vw, vh) then return end + -- A DISC RUNG: the two discs are the only ground there is, so they are the + -- only thing the sun has to see besides the Pokemon themselves. Everything + -- below this is a map that is not in the shot. + if arena.discs then + pcall(function() + V.require("StadiumStage").cast(ShadowMap, arena, groundY or 0) + end) + pcall(function() V.require("Stadium").cast(ShadowMap) end) + ShadowMap.finish(sig) + return + end + ShadowMap.draw(terrain, atlasFor(host), nil) for i, nb in ipairs(neighbors) do ShadowMap.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy)) @@ -354,6 +366,13 @@ local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, ShadowMap.snug(card.model)) end ShadowMap.sprites(false) + -- and the STADIUM models, when that rung is the one running. NOT marked + -- as sprites: that flag exists so a flat card's cut-out is kept off the + -- water (see ShadowMap.sprites), and these are real geometry standing in + -- the world -- a Gyarados at the water's edge should put a Gyarados on + -- the water. Un-snugged for the same reason: snug is a bias for a card + -- rooted to the ground plane, and a model has thickness of its own. + pcall(function() V.require("Stadium").cast(ShadowMap) end) ShadowMap.finish(sig) end @@ -363,6 +382,11 @@ end -- the one nearer the camera and therefore the one a mismatch would show up -- against. function BattleScene.groundY(map, arena) + -- A disc rung's discs are carried, not found: their tops ARE the ground + -- plane, so there is no terrain height to read and reading one would put + -- the stage at whatever elevation the map happens to have at a spot the + -- fight is not actually happening on + if arena and arena.discs then return 0 end local ok, h = pcall(VoxelScene.groundAt, map, arena.playerCell[1], arena.playerCell[2]) return (ok and h) or 0 @@ -452,10 +476,25 @@ function BattleScene.render(state, arena, textures, token) -- moving, and a shimmer on background windows would fight the mons Voxel3D.glassGlint = 0 + -- A B RUNG stands the fight on two carried discs against the sky, with no + -- map in the shot at all (see StadiumStage). Everything below still runs -- + -- the letterbox, the camera solve, the sun, the pins, the tint, the depth + -- of field -- because none of it is about the terrain; what changes is + -- which geometry the two passes draw. + local discs = arena.discs and true or false + -- shares the free-roam mode's request/evict bookkeeping, so a battle warms -- exactly the meshes walking around would have and nothing extra - local terrain, nbMesh, water, nbWater = prefetchArena(state, host) - if not terrain then return nil end + local terrain, nbMesh, water, nbWater + if discs then + -- and nothing is meshed for a disc fight, which is the other half of why + -- the rung works everywhere: there is no waiting for a chunk to build, so + -- the first frame of the first battle on a cold map is the finished shot + nbMesh, water, nbWater = {}, nil, {} + else + terrain, nbMesh, water, nbWater = prefetchArena(state, host) + if not terrain then return nil end + end local lx, ly, s, pw, ph = BattleScene.letterbox() if not (pw > 0 and ph > 0 and s > 0) then return nil end @@ -487,7 +526,7 @@ function BattleScene.render(state, arena, textures, token) local cards = monCards(arena, groundY, textures) Voxel3D.camera = nil castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, atlasFor, - cards, token, host, neighbors, water, nbWater) + cards, token, host, neighbors, water, nbWater, groundY) -- An opaque void either way. Outdoors the camera is low enough that the -- horizon is genuinely in frame, so it is sky; indoors it is the dark end @@ -496,6 +535,18 @@ function BattleScene.render(state, arena, textures, token) -- geometry stops. local sky = VoxelScene.skyColor(host, 1) or VoxelScene.skyShade(INDOOR_SHADE, 1) + -- On a disc rung the void is not a backdrop behind the scenery -- it IS the + -- scenery, because the map is not drawn. So outdoors it gets the full + -- treatment the free-roam camera gets: the banded gradient and the hour's + -- own sun or moon hanging in it (Voxel3D.beginScene paints those when the + -- sky it is handed carries bands). Indoors there is nothing to dress: a + -- room's void is one flat shade, which is what a room looks like past the + -- wall, and the disc fight in a cave is lit and coloured as that cave. + if discs and VoxelScene.skyColor(host, 1) then + local Sky = V.require("Sky") + local okDress, dressed = pcall(Sky.dress, sky) + if okDress and dressed then sky = dressed end + end Voxel3D.camera = cam -- the sun is turned up for the arena and put back afterwards, so the @@ -528,6 +579,13 @@ function BattleScene.render(state, arena, textures, token) if not Voxel3D.beginScene(rw, rh, cx, cy, vw, vh, sky, "battle") then return end + if discs then + -- discs: the two platforms, and nothing else. No terrain, no + -- neighbouring maps, no water, no grass and no flowers -- see the + -- matching skips further down. What is behind them is the sky the + -- clear painted. + V.require("StadiumStage").draw(arena, groundY) + else Voxel3D.draw(terrain, atlasFor(host), nil) for i, nb in ipairs(neighbors) do Voxel3D.draw(nbMesh[i], atlasFor(nb.map), @@ -549,6 +607,7 @@ function BattleScene.render(state, arena, textures, token) Mat4.translate(nb.ox, 0, nb.oy)) end end + end -- The mons, standing on their tiles. Depth-tested like everything else, -- so a ledge or a tree between the camera and a Pokemon really is in -- front of it, and the alpha discard cuts the sprite's own outline out of @@ -579,24 +638,35 @@ function BattleScene.render(state, arena, textures, token) end Voxel3D.glass(true) Voxel3D.seams(true) + -- and the STADIUM models, inside the same flash window and with the + -- same camera-ward pull, so a Pokemon standing on its tile still wins + -- the depth test against the tile. They manage the wireframe and the + -- glass mask around their own draws (StadiumRig), which is why this + -- sits outside the pair above rather than inside it. + local okStadium, stadiumErr = pcall(function() + V.require("Stadium").draw(BattleBillboard.PULL) + end) + if not okStadium then V.require("Stadium").report(stadiumErr) end if flashing then Voxel3D.flatten(nil) end -- grass and flowers ride the same camera-ward pull the free-roam pass -- gives them, measured against THIS camera's pitch rather than the -- orbit's -- there is no character here for them to overdraw, but the -- pull is also what keeps a tuft from z-fighting the floor it stands on local pull = VoxelScene.pull(math.max(pitch, 0.05)) - Voxel3D.draw(ChunkMesher.grass(host), atlasFor(host), nil, pull) - for _, nb in ipairs(neighbors) do - Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map), - Mat4.translate(nb.ox, 0, nb.oy), pull) - end - local fpull = math.max(0, pull - 8 * math.sin(math.max(pitch, 0.05))) - Voxel3D.draw(ChunkMesher.flowers(host), atlasFor(host), nil, fpull, - ShadowMap.snug(nil)) - for _, nb in ipairs(neighbors) do - Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map), - Mat4.translate(nb.ox, 0, nb.oy), fpull, - ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy))) + if not discs then + Voxel3D.draw(ChunkMesher.grass(host), atlasFor(host), nil, pull) + for _, nb in ipairs(neighbors) do + Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map), + Mat4.translate(nb.ox, 0, nb.oy), pull) + end + local fpull = math.max(0, pull - 8 * math.sin(math.max(pitch, 0.05))) + Voxel3D.draw(ChunkMesher.flowers(host), atlasFor(host), nil, fpull, + ShadowMap.snug(nil)) + for _, nb in ipairs(neighbors) do + Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map), + Mat4.translate(nb.ox, 0, nb.oy), fpull, + ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy))) + end end local canvas = AntiAlias.resolve(Voxel3D.endScene(), pw, ph, "battle") if not canvas then return end diff --git a/lib/ModSetting.lua b/lib/ModSetting.lua index 2bed605..33c44ad 100644 --- a/lib/ModSetting.lua +++ b/lib/ModSetting.lua @@ -47,6 +47,42 @@ local function indexOf(self, value) return 1 end +-- ------- rungs that are not always there +-- +-- A ladder may carry a rung that cannot be selected right now -- STADIUM +-- needs models built out of a ROM the player supplies, and until that has +-- happened there is nothing behind the option. `gate` is asked per rung and +-- decides whether it exists at all this frame. +-- +-- Skipped rather than shown-and-refused, deliberately. A row that can be +-- cycled onto and then does nothing is indistinguishable from a broken mod; +-- a row that simply has fewer stops reads as the mod not offering something, +-- which is the truth. What the player is missing, and how to get it, is said +-- once in the row's help text instead of implied by a dead setting. +-- +-- values[1] is never gated: it is the default and the fallback, so there is +-- always at least one rung to land on. +function ModSetting:setGate(gate) + self.gate = gate + return self +end + +function ModSetting:allows(i) + if i == 1 or not self.gate then return true end + local ok, allowed = pcall(self.gate, self.values[i], i) + return (not ok) or allowed and true or false +end + +-- How many rungs are live, for a caller that wants to know whether a row is +-- worth showing at all. +function ModSetting:rungs() + local n = 0 + for i = 1, #self.values do + if self:allows(i) then n = n + 1 end + end + return n +end + -- What the player left it at last session. Read lazily rather than at load -- time: the loader fills modOptions before a mod runs, but reading through -- the API keeps this honest about where the value lives. @@ -63,7 +99,13 @@ function ModSetting:read() end function ModSetting:get() - return self.values[self:read()] + local i = self:read() + -- a rung that was live when it was stored and is not now -- the player + -- moved the ROM, or opened the same save on another machine -- reads as + -- the default rather than as a mode with nothing behind it. The stored + -- value is left alone, so putting the ROM back restores their choice. + if not self:allows(i) then return self.values[1] end + return self.values[i] end function ModSetting:level() @@ -91,8 +133,32 @@ function ModSetting:setIndex(i, game) return value end +-- Set by the STORED VALUE rather than by its place on the ladder, for a +-- caller that knows which setting it wants and not where it sits -- a +-- preset, or an assertion. An unrecognised value lands on values[1], the +-- same default indexOf answers everywhere else, so this can never leave a +-- setting holding something the row cannot display. +-- +-- Worth having as its own entry point because a ladder's ORDER is not a +-- promise: 3D-BTL grew a third rung in the middle of itself (see +-- OverworldBattle), and every caller that had counted to two would have +-- silently meant something else afterwards. +function ModSetting:setValue(value, game) + return self:setIndex(indexOf(self, value), game) +end + +-- Step to the next rung that is actually live, in `dir`. Bounded by the +-- ladder's length so a gate that refuses everything still terminates on +-- values[1], which allows() never gates. function ModSetting:cycle(game, dir) - return self:setIndex(self:read() + (dir or 1), game) + dir = dir or 1 + local n = #self.values + local i = self:read() + for _ = 1, n do + i = ((i + dir - 1) % n + n) % n + 1 + if self:allows(i) then break end + end + return self:setIndex(i, game) end -- Adopt a value set from somewhere else (the mod manager's settings page, @@ -109,7 +175,12 @@ function ModSetting:row() return { id = "DRAMATIC_SHAPE:" .. self.key, label = self.label, - value = function() return self_.labels[self_:read()] end, + -- the label of the rung actually in force, which is not the stored one + -- when that rung has been gated away (see get) + value = function() + local i = self_:read() + return self_.labels[self_:allows(i) and i or 1] + end, step = function(game, dir) self_:cycle(game, dir) return true @@ -120,7 +191,11 @@ end -- The row the mod manager's own settings page builds for this mod. function ModSetting:schema(help) local choices = {} - for i, v in ipairs(self.values) do choices[i] = { self.labels[i], v } end + -- gated rungs are left off the manager's page too, so the two rows agree + -- about what can be chosen + for i, v in ipairs(self.values) do + if self:allows(i) then choices[#choices + 1] = { self.labels[i], v } end + end if #self.values == 2 and self.values[1] == false then return { key = self.key, type = "toggle", label = self.label, default = self.values[1], help = help } diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index 2114264..7e29167 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -60,13 +60,80 @@ if DEBUG == nil or DEBUG == false then DEBUG = nil end OverworldBattle.KEY = "battles" OverworldBattle.LABEL = "3D-BTL" --- On by default: a mod whose headline is "the world in 3D" should not need --- the player to go and find the switch before the world shows up in a --- battle. ON is first, so it is also what an unreadable stored value falls --- back to. -OverworldBattle.setting = ModSetting.new(OverworldBattle.KEY, - OverworldBattle.LABEL, - { true, false }, { "ON", "OFF" }) +-- Five rungs. Two independent choices, laid out as one ladder because they +-- are one question to the player -- WHAT is standing there, and WHERE: +-- +-- on the MAP on two DISCS +-- pics 2D-3D A 2D-3D B +-- models STADIUM A STADIUM B +-- +-- 2D-3D A the mode this file was written for: the fight is staged on +-- the map and the two Pokemon are the GB's OWN PICS, stood up +-- on their tiles as quads (BattleBillboard). +-- 2D-3D B those same pics on a pair of DISCS against the sky, with no +-- map at all (see lib/StadiumStage.lua). The Game Boy's own +-- framing with the Game Boy's own art, in three dimensions -- +-- and, like every B rung, it works everywhere, including the +-- caves and shop floors that have nowhere to stage a fight. +-- STADIUM A the staged fight with the Pokemon Stadium battle models in +-- place of those quads -- skinned, animated, and playing the +-- animation the move being used actually calls for (see +-- lib/Stadium.lua). The world is still the world: the fight +-- happens on real ground, in the map's own weather and light. +-- STADIUM B the models on the discs: both halves swapped at once. +-- OFF the engine's own white battle screen. +-- +-- A and B is the STAGE and it is the same stage either way -- the discs do +-- not know what is standing on them and BattleScene draws them off +-- `arena.discs` alone, which is why the second column cost a value in this +-- table and nothing else. The four combinations are all reachable rather +-- than only the diagonal, because a player who cannot use the STADIUM rungs +-- -- no ROM, or a ROM they would rather not go and find -- should still be +-- able to have the disc framing, and because the discs are the answer to +-- "this map has nowhere to fight" whichever art is standing on them. +-- +-- 2D-3D A stays FIRST because ModSetting's values[1] is both the default and +-- what an unrecognised stored value falls back to, and the stored value for +-- this row has been `true` since the row existed. Keeping `true` at the head +-- means every save written before the later rungs existed reads back as the +-- 2D-3D it was written for, and a mod whose headline is "the world in 3D" +-- still does not need the player to go and find the switch. +-- +-- Every other stored value is likewise the one it has always been -- +-- "stadium" from before there was a B, "stadiumB" from before there was a +-- flat one -- so no save loses the mode it chose. +-- +-- Both STADIUM rungs are GATED on the models existing: the mod ships no +-- Pokemon Stadium data, and until the player's own ROM has been found and +-- built from (StadiumInstall) the row simply has two fewer stops. See +-- ModSetting.setGate for why they are skipped rather than shown and refused. +-- 2D-3D B is NOT gated: its stage is generated in Lua and its Pokemon are +-- the game's own art, so it needs nothing the base game did not ship. +OverworldBattle.FLAT_B = "flatB" + +OverworldBattle.setting = + ModSetting.new(OverworldBattle.KEY, OverworldBattle.LABEL, + { true, "flatB", "stadium", "stadiumB", false }, + { "2D-3D A", "2D-3D B", "STADIUM A", "STADIUM B", "OFF" }) + :setGate(function(value) + if value ~= "stadium" and value ~= "stadiumB" then return true end + local ok, install = pcall(V.require, "StadiumInstall") + return ok and install and install.available() + end) + +-- Whether the fight stands on the two carried DISCS rather than on the map +-- -- the B column above, whichever row of it. Asked by stageFor (what to +-- stage on), wantsFront (whether this map needs an arena at all) and, once +-- the arena carries the answer as `arena.discs`, by BattleScene and +-- VoxelScene for what to draw. +-- +-- Read straight off the row rather than through Stadium, because it is a +-- question about the STAGE and half the rungs that answer yes have no +-- Stadium models on them at all. +function OverworldBattle.discs() + local value = OverworldBattle.setting:get() + return (value == OverworldBattle.FLAT_B or value == "stadiumB") +end -- Whether the VR row is ON -- read lazily, because VR requires modules -- that sit above this one. While it is, this mode stops being optional: @@ -83,6 +150,15 @@ function OverworldBattle.enabled() return OverworldBattle.setting:get() and true or false end +-- Whether the STADIUM rung is the one selected -- read through Stadium so +-- there is one answer to that question and it lives with the mode it +-- describes. Required lazily: Stadium sits above this file and requires it +-- back (for the row), which a load-time require would deadlock. +function OverworldBattle.stadium() + local ok, stadium = pcall(V.require, "Stadium") + return (ok and stadium and stadium.enabled()) and true or false +end + -- ------- BACK SPRITES: the player's own mon stays on the menu -- -- The staged shot stands BOTH mons on the map, which is the mode's whole @@ -173,6 +249,9 @@ function OverworldBattle.wantsFront() local g = require("src.core.Game") local ow = g and g.overworld if not (ow and ow.map and ow.player) then return false end + -- a B rung carries its own stage, so the answer is yes on every map and + -- there is nothing to search or to cache + if OverworldBattle.discs() then return true end if staged.mapId ~= ow.map.id then local ok, arena = pcall(BattleArena.find, ow.map, ow.player.cellX, ow.player.cellY, @@ -420,6 +499,30 @@ function OverworldBattle.forceOG(g) return true end +-- Where THIS fight stands, on whichever rung is running: the map's own +-- ground, or the pair of discs a B rung carries with it. +-- +-- The one place the two columns actually diverge, and it is worth stating +-- plainly. On an A rung the answer can be NO -- a corridor, a shop floor, a +-- map whose authored entry is a refusal -- and the battle then plays exactly +-- as the vanilla game does. A B rung cannot fail: its stage is not something +-- the map has to have room for, so a fight in the tightest cave in Kanto is +-- staged as readily as one on Route 1. +function OverworldBattle.stageFor(state) + if OverworldBattle.discs() and Voxel3D.available() then + local okStage, arena = pcall(function() + return V.require("StadiumStage").arena(state.map) + end) + if okStage and arena then return arena end + -- the discs could not be built; fall through to the map, which is a + -- worse picture but a real one + end + local okFind, arena = pcall(BattleArena.find, state.map, + state.player.cellX, state.player.cellY, + state.player.surfing) + return (okFind and arena) or nil +end + -- Stage a battle triggered from `state`, if this mode can. Returns true when -- a session started -- which is also the only case where anything visible -- changes, so a map with no room for an arena plays exactly the vanilla @@ -430,10 +533,8 @@ function OverworldBattle.begin(state, battle) if not (state and state.map and state.player) then return false end if not Voxel3D.available() then return false end - local ok, arena = pcall(BattleArena.find, state.map, - state.player.cellX, state.player.cellY, - state.player.surfing) - if not (ok and arena) then return false end + local arena = OverworldBattle.stageFor(state) + if not arena then return false end -- the fight is staged from here on, so the layout it is composed for is not -- optional any more (see forceOG) @@ -443,6 +544,9 @@ function OverworldBattle.begin(state, battle) armed = false, token = 0 } cullCast(state) BattleCam.reset() + -- and, on the STADIUM rung, the pair of models that will stand on this + -- arena's two cells. Declines quietly on any other rung. + pcall(function() V.require("Stadium").begin(arena) end) return true end @@ -473,6 +577,7 @@ function OverworldBattle.finish() restoreCast() session = nil Voxel3D.camera = nil + pcall(function() V.require("Stadium").finish() end) end -- ------- per-frame @@ -524,6 +629,17 @@ function OverworldBattle.update(dt) -- slice: nothing visible can hitch on them ChunkMesher.pump(true) + -- The STADIUM models, ahead of the pics, because what they decide is + -- WHICH pics are needed: a side a model is standing on gets no billboard + -- texture rendered for it at all (see Stadium.covers). Posed and skinned + -- here too, once for the frame -- the sun pass, the camera and, in a + -- headset, both eyes all draw the same skinned meshes. + pcall(function() + local host = (session.arena and session.arena.map) or session.state.map + V.require("Stadium").update(dt, session.battle, + BattleScene.groundY(host, session.arena)) + end) + -- The mons' textures are rendered HERE, with no canvas bound, for the same -- reason the scene is: the pics layer binds its own targets, and doing that -- inside somebody else's frame means putting the frame back afterwards. @@ -710,6 +826,9 @@ function OverworldBattle.invalidate() BattleDOF.invalidate() BattleHud.invalidate() BattlePics.invalidate() + -- the STADIUM models hold meshes and textures of this graphics context + -- like everything else here does + pcall(function() V.require("Stadium").invalidate() end) end -- ------- the battle screen's background @@ -908,6 +1027,14 @@ local OFF = { -- feet ended up, in canvas coordinates. function OverworldBattle.sideTexture(battle, side) if not (innerPics and battle) then return nil end + -- On the STADIUM rung a side standing a MODEL needs no pic: rendering one + -- anyway would hang a second, flat copy of the same Pokemon on the same + -- cell. Asked per side, so a species with no pack -- or a substitute + -- doll, or the trainer before the send-out -- still comes through here. + local okS, covered = pcall(function() + return V.require("Stadium").covers(battle, side) + end) + if okS and covered then return nil end if not sideVisible(battle, side) then return nil end local canvas = texCanvasFor(side) if not canvas then return nil end @@ -985,7 +1112,16 @@ function OverworldBattle.textures(battle) end out.enemy = okE and enemy or nil out.player = okP and player or nil - if not (out.enemy or out.player) then return nil end + -- On the STADIUM rung both sides can legitimately have no pic -- the pair + -- of them are models -- and this table must still come back, because it + -- carries the HIT FLASH, and because the VR eye pass uses its presence to + -- decide there is a staged fight to draw at all. + local okStanding, standing = pcall(function() + return V.require("Stadium").standing() + end) + if not (out.enemy or out.player or (okStanding and standing)) then + return nil + end out.flash = OverworldBattle.flashing(battle) return out end @@ -1008,6 +1144,12 @@ function OverworldBattle.install() OverworldState.dramaticShapeBattleHook = true end + -- the STADIUM rung's own four wraps, which drive the models' animations + -- off the fight (see Stadium.install). Idempotent in the same way, and + -- installed whichever rung the row is on: the wraps do nothing at all + -- while no stadium session is live. + pcall(function() V.require("Stadium").install() end) + local BattleState = require("src.battle.BattleState") if BattleState.dramaticShapeBattleHook then return end @@ -1130,18 +1272,14 @@ function OverworldBattle.install() end -- The battle's text box and its menus, over the frosted glass laid down for - -- them rather than over their own white paper -- and their ink flipped with - -- the HUD's when the ground under the frame is dark, by the same rule and - -- off the same verdict. + -- them rather than over their own white paper. The INK is Gen 1's own black + -- and stays that way whatever is behind the glass -- the panel's tint is + -- what earns it its contrast (see BattleHud). local innerText = BattleState.drawTextArea function BattleState:drawTextArea() if not self.dramaticShapeShot then return innerText(self) end if isIOS() then return innerText(self) end - local battle = self - if not self.dramaticShapeDark then return withoutBoxFill(battle, innerText) end - BattleHud.flipGlyphs(BattleScene.GB_W, BattleScene.GB_H, function() - withoutBoxFill(battle, innerText) - end) + return withoutBoxFill(self, innerText) end -- Move animations are authored against the pics' fixed slots, and a single @@ -1234,28 +1372,13 @@ function OverworldBattle.install() if not ok then error(err, 0) end end - -- Black glyphs on grass are not readable; over a frosted panel measured - -- dark they are not readable either, so they go white. Mapped rather than - -- rewritten: the HUD sets pure black for its text and nothing else, and in - -- the colorized pipeline this lands in the grayscale BG canvas, where - -- white IS shade 0 and the zone pass then colours it like every other - -- lightest-shade surface. One rule, both pipelines. - -- - -- The HP bar is untouched: it is drawn in its own greens and reds, and - -- only an exactly-black set is remapped. innerHUDs = BattleState.drawHUDs function BattleState:drawHUDs(slide) -- Normally the HUDs have already been drawn this frame, snapped out to the -- window's edges and composited into the world image (snapHUDs). Drawing -- them here as well would show each block twice, once in each place. if self.dramaticShapeShot and snapped() then return end - if not (self.dramaticShapeShot and self.dramaticShapeDark) then - return innerHUDs(self, slide) - end - local battle = self - BattleHud.flipGlyphs(BattleScene.GB_W, BattleScene.GB_H, function() - innerHUDs(battle, slide) - end) + return innerHUDs(self, slide) end BattleState.dramaticShapeBattleHook = true @@ -1287,18 +1410,17 @@ end -- outside the frame that pass covers. In the colorized pipeline drawHUDs leaves -- the HP bar's fill as DMG gray for the zone pass to colour by region (#229); -- answered false, it tints its own greens and reds instead, exactly as it does --- on the flat path. The glyphs are pure black either way, which is what the --- flip in BattleHud.layerTexture is measured against. +-- on the flat path. -- -- Shadowed on the instance for this call only, the way drawZonePass shadows -- activeBgp: putting the field back to whatever it was (normally nil) lets the -- class method be found again. -function OverworldBattle.hudTexture(battle, slide, dark) +function OverworldBattle.hudTexture(battle, slide) if not (innerHUDs and battle) then return nil end local had = rawget(battle, "colorMode") battle.colorMode = function() return false end local ok, layer = pcall(BattleHud.layerTexture, - BattleScene.GB_W, BattleScene.GB_H, dark, + BattleScene.GB_W, BattleScene.GB_H, function() innerHUDs(battle, slide) end) battle.colorMode = had return ok and layer or nil @@ -1337,16 +1459,7 @@ function OverworldBattle.snapHUDs(battle, shot) for key, rect in pairs(OverworldBattle.textRects(battle)) do live[key] = toWorld(rect, shot) end - -- measured under the SNAPPED rects: the panels are over whatever the world - -- shows at the window's edges now, which is not what was behind them in the - -- middle of the frame. ONE verdict over all of them, HUDs and box together, - -- for the reason BattleHud.verdict gives: a frame with white glyphs in the - -- corner and black ones on the menu reads as a bug rather than as adaptation. - local dark = BattleHud.verdict(live, shot, true) - -- the box's own ink is flipped where the engine draws it, in the GB frame, - -- so the answer has to outlive this function (see drawHudPanels) - if session then session.dark = dark end - local layer = OverworldBattle.hudTexture(battle, slide, dark) + local layer = OverworldBattle.hudTexture(battle, slide) if not layer then return false end local g = love.graphics @@ -1355,7 +1468,7 @@ function OverworldBattle.snapHUDs(battle, shot) local ok, err = pcall(function() g.setCanvas(shot.canvas) g.setBlendMode("alpha") - for _, rect in pairs(live) do BattleHud.panel(rect, shot, dark, true) end + for _, rect in pairs(live) do BattleHud.panel(rect, shot, true) end g.setColor(1, 1, 1, 1) for side, band in pairs(OverworldBattle.HUD_BAND) do local quad = g.newQuad(band[1], band[2], band[3], band[4], @@ -1380,7 +1493,6 @@ end -- drawn here, in the GB frame, whichever path laid the glass under it. function OverworldBattle.drawHudPanels(battle) local shot = battle.dramaticShapeShot - battle.dramaticShapeDark = nil if not shot then return end if isIOS() then local slide = (battle.introSlide or 0) * 4 @@ -1390,11 +1502,9 @@ function OverworldBattle.drawHudPanels(battle) if enemy then love.graphics.rectangle("fill", rect.enemy[1], rect.enemy[2], rect.enemy[3], rect.enemy[4]) end if player then love.graphics.rectangle("fill", rect.player[1], rect.player[2], rect.player[3], rect.player[4]) end love.graphics.setColor(1, 1, 1, 1) - battle.dramaticShapeDark = nil - return + return end if snapped() then - battle.dramaticShapeDark = session and session.dark or nil return end local slide = (battle.introSlide or 0) * 4 @@ -1405,9 +1515,7 @@ function OverworldBattle.drawHudPanels(battle) if player then live.player = rect.player end for key, r in pairs(OverworldBattle.textRects(battle)) do live[key] = r end if not next(live) then return end - local dark = BattleHud.verdict(live, shot) - battle.dramaticShapeDark = dark - for _, r in pairs(live) do BattleHud.panel(r, shot, dark) end + for _, r in pairs(live) do BattleHud.panel(r, shot) end end return OverworldBattle diff --git a/lib/Stadium.lua b/lib/Stadium.lua new file mode 100644 index 0000000..aceeab9 --- /dev/null +++ b/lib/Stadium.lua @@ -0,0 +1,730 @@ +-- STADIUM battles: the two Pokemon as real 3D models. +-- +-- The 3D-BTL row's two STADIUM rungs. OFF is the engine's own white battle +-- field; the 2D-3D rungs stand the GB's own pics up as quads +-- (BattleBillboard); STADIUM replaces those quads with the Pokemon Stadium +-- battle models -- skinned, animated, and playing the animation the move +-- being used actually calls for. A or B decides whether that happens on the +-- map or on two discs, and is the same choice on either pair of rungs. +-- +-- The models come out of the Stadium ROM through model_extract, and are +-- packed into assets/stadium/NNN.dsm by tools/stadium_pack.py. Nothing here +-- knows about the ROM; the pack is the interface. +-- +-- ------- what this file is, and is not +-- +-- It is the MODE: which species is out on each side, which animation the +-- fight is asking each of them for, whether the model or the flat pic is +-- standing in this frame, and the two draw calls. The arithmetic is +-- StadiumRig's, the file format is StadiumPack's, and one side's own state +-- is StadiumMon's. +-- +-- It is not a rewrite of the staged battle. The arena is picked the same +-- way, the camera is solved the same way, the HUDs and the text box and the +-- move animations and the depth of field are all exactly what 2D-3D draws +-- -- because all of those are hung off the arena's CELLS, not off the +-- pics. Swapping what stands on a cell changes nothing about where the cell +-- projects to. That is why this is an option on the mode rather than a +-- second mode. +-- +-- ------- declining, per Pokemon +-- +-- Every gate here is per SIDE and per FRAME, not per battle: +-- +-- no pack for that species, or its meshes would not build -> that side +-- falls back to its flat pic, and the other side keeps its model +-- +-- the side is showing a TRAINER (the foe's class before the send-out, +-- the player's own back before "Go!") -> that is not a Pokemon and there +-- is no model for it; the pic stands, exactly as in 2D-3D +-- +-- a SUBSTITUTE is up -> the engine replaces the pic with the mini doll, +-- which is the thing the player is being told is there. A model of the +-- Pokemon behind the doll would be a lie about the battle state. +-- +-- So `covers` is asked per side per frame, and OverworldBattle renders a +-- billboard texture for exactly the sides it answers false for. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local Voxel3D = V.require("Voxel3D") +local StadiumPack = V.require("StadiumPack") +local StadiumMon = V.require("StadiumMon") + +local Stadium = {} + +-- The stored values of the two 3D-BTL rungs that select this mode. Strings +-- rather than further booleans so an older save's `true` still means the +-- 2D-3D it was written for (see OverworldBattle.setting). +-- +-- A the models on the MAP -- real ground, the map's own light and sky +-- B the models on two DISCS against the sky, with no map at all +-- +-- Everything below is shared: which species is out, which animation the +-- fight is asking for, the skinning, the draw. The difference is entirely +-- in what the camera is pointed at, which is BattleScene's business and +-- StadiumStage's. +Stadium.VALUE = "stadium" +Stadium.VALUE_B = "stadiumB" + +-- ------- the live pair + +local session = nil -- nil when no staged fight is running + +local function game() + return require("src.core.Game") +end + +-- Whether the row is on this rung. Deliberately NOT gated on whether the +-- packs are installed: a mod folder without assets/stadium still cycles the +-- row, and each Pokemon declines on its own when its pack does not load -- +-- which is one message on the console rather than a row that silently +-- refuses to move. +function Stadium.selected() + return Stadium.mode() ~= nil +end + +-- "A", "B", or nil when the row is on neither stadium rung. +function Stadium.mode() + local OverworldBattle = V.require("OverworldBattle") + local value = OverworldBattle.setting:get() + if value == Stadium.VALUE then return "A" end + if value == Stadium.VALUE_B then return "B" end + return nil +end + +-- Whether the fight is staged on the DISCS rather than on the map. +-- +-- Not this file's question any more: the flat 2D-3D B rung stands the game's +-- own pics on the same two discs with no model anywhere in the frame, so the +-- stage and the actors are chosen separately (see OverworldBattle's ladder). +-- Kept as a forwarder because "are we on discs" is a fair thing to ask the +-- module named after the mode, and because the shot drivers ask it here. +function Stadium.discs() + return V.require("OverworldBattle").discs() +end + +function Stadium.enabled() + if not Stadium.selected() then return false end + return Voxel3D.available() +end + +-- A staged fight has begun on `arena`. Called from OverworldBattle.begin, +-- which is the one place that knows a fight is being staged at all. +function Stadium.begin(arena) + Stadium.finish() + if not Stadium.enabled() then return false end + -- a new fight gets its own first complaint: `reported` is a one-shot so the + -- console is not filled sixty times a second, but latched for the whole + -- process it would swallow every failure after the first one ever + Stadium.reported = false + session = { + arena = arena, + groundY = 0, + player = StadiumMon.new("player"), + enemy = StadiumMon.new("enemy"), + -- what each side has been TRANSFORMED into, if anything (see install) + transform = {}, + -- sides that are going to collapse, but whose HP bar has not finished + -- emptying yet (see faintReady) + faintPending = {}, + -- who was standing in each slot last frame, so a replacement is noticed + -- even when it is the same species (see update) + at = {}, + } + return true +end + +function Stadium.finish() + if not session then return end + session.player:release() + session.enemy:release() + session = nil +end + +function Stadium.active() + return session ~= nil +end + +-- ------- which species each side is showing + +-- The National Dex number for a battler, which is the number the Stadium +-- packs are keyed by. The engine's species are string keys ("PIKACHU") and +-- carry their dex number on the definition, so this is one lookup rather +-- than a table of its own. +local function dexOf(species) + if not species then return nil end + local data = game() and game().data + local def = data and data.pokemon and data.pokemon[species] + return def and def.dex or nil +end + +-- Whether this side is showing a TRAINER rather than a Pokemon. +local function showingTrainer(battle, side) + if side == "enemy" then + return (battle.showEnemyTrainer and battle.trainerPic) and true or false + end + return (battle.showPlayerBack and battle.playerBackPic) and true or false +end + +-- Whether this side has anything on the field at all this frame. +-- +-- Mirrors BattleState's own guards, the same way OverworldBattle.sideVisible +-- mirrors them for the flat cards: there is no seam that reports "the foe is +-- off screen right now", and a model left standing through a send-out or a +-- damage blink would be the one thing in the frame that ignored the battle. +-- ------- and the collapse gets to finish +-- +-- A fainted Pokemon leaves the field when its pic does, which is the end of +-- the engine's slide -- SlideDownFaintedMonPic, seven rows two frames apart, +-- FOURTEEN frames of a 60 Hz clock. Under a quarter of a second. +-- +-- The Stadium faint animations are nothing like that short. The briefest in +-- the set is 49 frames of a 30 Hz clock -- a second and two thirds -- the +-- median is 110 and the longest 230, which is nearly eight seconds. Held to +-- the pic's window every one of them was cut off inside its first fifth: the +-- Pokemon began to fall and vanished mid-fall, which is worse than not +-- animating at all, because the eye has been told something is happening and +-- then had it taken away. +-- +-- So a model that is COLLAPSING stays until it has finished collapsing, and +-- the two timings stop being tied to each other. That is the whole of the +-- divergence: the slide is how long a flat pic takes to slide off the bottom +-- of a 160x144 frame, and it has nothing to say about how long it takes a +-- Gyarados to fall over. +-- +-- Bounded at both ends rather than open-ended. It ends when the animation +-- does (StadiumMon.finished), not when the battle moves on -- so nothing is +-- left lying on the field for the rest of the fight -- and the side is reset +-- outright the moment a different battler stands in that slot (see update), +-- which is what stops the next Pokemon out of the ball arriving face down. +local function onField(battle, side, mon) + local battler = side == "player" and battle.player or battle.enemy + if not (battler and battler.sprite) then return false end + -- A model that is GROWING out of its ball is on the field by definition -- + -- that is what the grow is -- even though the engine still calls the side + -- "sending out", because the flat pic it wrote that flag for does not + -- appear until the ball has finished opening and this one comes out with + -- it (see StadiumMon.GROW_TIME). + local growing = (mon and mon.grow) and true or false + if side == "enemy" then + if battle.enemyHidden then return false end + if battle.enemySendingOut and not growing then return false end + else + if battle.safari or battle.demo then return false end + if battle.sendingOut and not growing then return false end + -- ------- and not before the battle has even opened + -- + -- The player's Pokemon is not out during the INTRO. Every other guard + -- here is a field the engine sets once the battle is running, and during + -- the opening none of them is set yet: `showPlayerBack` is still nil + -- (BattleState assigns it further in, when the back pic is built), + -- `playerBackPic` is nil with it, and `sendingOut` does not go true until + -- the ball is actually thrown. So the whole opening read as "this + -- Pokemon is standing on the field" and the model was drawn through it -- + -- two and a half seconds of it, on its tile, playing its standby loop, + -- before the trainer sprite it is supposed to be hiding behind had even + -- appeared. It then vanished when that sprite arrived and came back with + -- its entrance when the ball opened, so the first Pokemon of a battle + -- appeared, left and arrived again. + -- + -- A SWITCH has no intro, which is why a switch always looked right and + -- was the thing worth comparing against. + -- + -- Gated on the PHASE rather than on a flag latched at the send-out: a + -- latch that never fires (a link battle, a script pushing a battle + -- straight to the menu) would hide the Pokemon for good, and being wrong + -- in that direction is far worse than the two seconds this fixes. + if battle.phase == "intro" then return false end + end + local ok, hidden = pcall(battle.fxHidden, battle, battler) + if ok and hidden then return false end + -- ------- FLY and DIG: the Pokemon that is not there + -- + -- `fxHidden` above is the damage BLINK and nothing else. The other way a + -- Pokemon leaves the screen -- the important one -- is the engine's + -- per-battler pic program, `picFx`, and that is where the two-turn moves + -- live: FLY runs SE_SLIDE_MON_OFF and DIG SE_SLIDE_MON_DOWN on the charge + -- turn, each a 19-24 frame slide that ENDS by setting `hidden`, and the + -- release turn puts the pic back through SE_SLIDE_MON_UP / + -- SE_SHOW_MON_PIC. Every other vanishing act is the same field: the user + -- of Explosion, a Pokemon that has been Teleported away. + -- + -- Without this the model simply stood on its tile while the game said it + -- was underground -- and said it in the strongest way it has, by making + -- every attack aimed at it miss. That is the one thing in the frame + -- contradicting the battle it is part of. + -- + -- Read as the engine's own answer rather than as a list of moves: this + -- mode's whole method is to let the battle decide and follow it, and a + -- table of move ids here would be a second place for the same facts to + -- live and would go stale against a mod that adds a third one. + -- + -- The engine's slide is 19-24 frames, so the model plays the opening of + -- its own FLY or DIG animation while the pic slides and is gone when the + -- pic is. It is NOT held to the end of that animation the way a collapse + -- is (see below), and the difference is not an oversight: the Stadium + -- animations are authored as the WHOLE move -- Charizard's DIG is 3.83 + -- seconds of burrow, emerge and hit -- because Stadium plays it in one + -- turn. Gen 1 splits it across two, so cutting at the engine's own hide + -- shows the burrowing and holds the strike back for the turn it lands on, + -- which is the right half of the animation for the turn being played. + local pf = battle.picFx and battle.picFx[battler] + if pf and pf.hidden then return false end + if battler.fainted then + local okF, sliding = pcall(battle.fxFaintActive, battle, battler) + if okF and sliding then return true end + -- the pic has finished sliding away; the model has not finished falling + return (mon and mon.state == "faint" and not mon:finished()) and true + or false + end + return true +end + +Stadium._onField = onField + +-- Whether the 3D model is standing in for this side's pic this frame. The +-- one question OverworldBattle asks, and the answer that decides whether a +-- billboard texture gets rendered for that side at all. +function Stadium.covers(battle, side) + if not (session and battle) then return false end + local mon = session[side] + if not (mon and mon.rig) then return false end + if showingTrainer(battle, side) then return false end + local battler = side == "player" and battle.player or battle.enemy + -- the substitute doll is what the player is being shown is out there + if battler and battler.substituteHP then return false end + return true +end + +-- ------- the collapse waits for the bar +-- +-- `onFaint` runs the instant HP reaches zero, which is NOT when a Pokemon +-- falls over. The engine queues the collapse -- the slide, the cry, the +-- "fainted!" line -- to run after the move animation and the HP-bar drain +-- (BattleState.onFaint's own comment), and the drain takes real frames: a +-- 150 HP mon's bar walks down over some four seconds. +-- +-- So asking for the faint animation at `onFaint` played it against a bar +-- that was still emptying: the Pokemon lay down, and then its health went on +-- draining above the corpse. What the player reads as the moment of death is +-- the bar hitting zero, and that is what this waits for. +-- +-- `shownHP` is the engine's own bar position (BattleState.stepHPDrain walks +-- it toward mon.hp a point at a time), so this is not a guess at the timing +-- -- it is the same number the bar is drawn from. +local function faintReady(battler) + if not battler then return false end + -- nothing is animating the bar for this battler: there is nothing to wait + -- for, and waiting forever would mean never collapsing at all + if battler.shownHP == nil then return true end + return battler.shownHP <= 0 +end + +-- Whether a pending collapse is still owed. A switch, a revive or a battler +-- that was replaced under us drops it rather than firing late at whoever is +-- standing there now. +local function faintStillDue(battler) + return (battler and battler.faintQueued + and battler.mon and (battler.mon.hp or 0) <= 0) and true or false +end + +-- named for the suite: these timing rules are the whole of what decides when +-- a Pokemon falls and when it goes, and they are testable without a graphics +-- context where the mode itself is not +Stadium._faintReady = faintReady +Stadium._faintStillDue = faintStillDue + +-- ------- per frame +-- +-- Runs from OverworldBattle.update, before the pics are rendered and before +-- the scene is drawn: what this decides is exactly which sides need a pic. +function Stadium.update(dt, battle, groundY) + if not session then return end + session.groundY = groundY or session.groundY or 0 + if not battle then return end + + local arena = session.arena + for _, side in ipairs({ "enemy", "player" }) do + local mon = session[side] + local battler = side == "player" and battle.player or battle.enemy + local dex = nil + if battler and not showingTrainer(battle, side) then + dex = session.transform[side] or dexOf(battler.mon and battler.mon.species) + end + + -- A DIFFERENT POKEMON IS IN THIS SLOT. Normally that shows up as a + -- change of species and setSpecies rebuilds everything -- but a trainer + -- who leads with two Rattata sends the second one out onto the first + -- one's dex number, so nothing downstream would notice. What it would + -- inherit is the state, and the state after a faint is `faint`, which + -- refuses every request there is (see StadiumMon.request -- a faint is + -- meant to be final). The new Pokemon would arrive lying on the ground. + -- + -- The battler TABLE is the identity here rather than the species or the + -- mon: it is the slot's occupant, and the engine replaces it on a switch, + -- a send-out and a new battle alike. + if session.at[side] ~= battler then + session.at[side] = battler + -- 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 + end + -- the collapse this side is owed, once its bar has finished emptying + if session.faintPending and session.faintPending[side] then + if not faintStillDue(battler) then + session.faintPending[side] = nil + elseif faintReady(battler) then + session.faintPending[side] = nil + if mon and mon.rig then mon:request("faint") end + end + end + + mon:setSpecies(dex) + -- 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 + mon.visible = (mon.rig ~= nil) and onField(battle, side, mon) + and not (battler and battler.substituteHP) + -- cleared up front, so a side that has just lost its rig cannot leave + -- last frame's matrix behind it + mon.model_matrix = nil + + if mon.rig then + -- ------- the ball is opening: start growing out of it + -- + -- The POOF is the ball coming apart, and it is where a Pokemon should + -- begin to exist -- not 27 frames later when the engine starts scaling + -- up the flat pic it was written for. Only for a side the battle says + -- is actually sending out, so the same animation played at a thrown + -- Poke Ball (a capture attempt, which aims it at the FOE) cannot start + -- the wrong Pokemon growing. + local poof = (battle.animPlaying + and battle.animName == "POOF_ANIM") and true or false + local sending = (side == "player") and battle.sendingOut + or battle.enemySendingOut + if poof and sending and mon:beginGrow() then + -- and the arrival animation with it, so the whole thing is one + -- performance rather than a grow followed by a flourish + mon:request("entrance") + end + + -- how big it is drawn. Its own ramp while it is growing (see + -- StadiumMon.growScale); the engine's three-step one otherwise, which + -- still covers a send-out that never showed a poof. + if mon.grow then + mon.scale = mon:growScale() + elseif mon.grewOwn then + mon.scale = 1 + else + local okG, grow = pcall(battle.growInScale, battle, battler) + mon.scale = (okG and grow) or 1 + end + mon:update(dt or 0) + if mon.visible and arena then + local cell = arena[side] + local other = arena[side == "player" and "enemy" or "player"] + if cell and other then + -- posed and skinned inside the same guard the draws use: this is + -- where a bad track or a released texture is first touched, and a + -- throw here would take the OTHER side's update with it (the + -- caller wraps this whole function in one pcall) + Stadium.guard(side, mon, "build", function() + mon.model_matrix = mon:matrix(cell[1], session.groundY, cell[2], + other[1] - cell[1], + other[2] - cell[2]) + mon:build() + end) + else + mon.model_matrix = nil + end + else + mon.model_matrix = nil + end + end + end + Stadium.debug(dt) +end + +-- ------- the draws +-- +-- Both take the pass as they find it: this is called from inside +-- BattleScene's own beginScene/endScene window (and, in a headset, from +-- VoxelScene's), so the camera, the shadow map, the hour's tint and the hit +-- flash are all already set. StadiumRig turns the wireframe and the glass +-- mask off around its own draws and puts them back. + +-- ------- one model going wrong is not both +-- +-- These two draws used to be a bare loop inside the caller's single pcall, +-- which had two consequences and both were bad. A throw on the FIRST side +-- skipped the second, so one broken Pokemon took its opponent off the screen +-- with it. And nothing recorded that it had happened, so the same throw came +-- back every frame for the rest of the fight -- the mode's own fallback (that +-- side draws its flat pic instead) was sitting right there and never reached, +-- because falling back needs somebody to decide the model is not working. +-- +-- So each side is drawn inside its own pcall, and a side that throws is +-- RETIRED: its rig is released, which is exactly the state a species with no +-- pack is in, and OverworldBattle renders a billboard for it from the next +-- frame on. The fight carries on with a flat Pokemon instead of a missing +-- one, which is the difference the player actually sees. +-- On the TABLE rather than a local, because Stadium.update calls it and sits +-- above this line: a local would still be nil there. +function Stadium.guard(side, mon, what, fn) + local ok, err = pcall(fn) + if ok then return true end + Stadium.report(err) + -- release rather than merely hide: the rig holds meshes and texture + -- references, and whatever went wrong with them is not going to be better + -- next frame. setSpecies rebuilds from scratch if this Pokemon is sent out + -- again later. + if mon.rig then pcall(mon.release, mon) end + mon.rig, mon.visible, mon.model_matrix = nil, false, nil + if session then session.broken = session.broken or {} end + if session then session.broken[side] = what end + return false +end + +function Stadium.draw(pull) + if not session then return end + for _, side in ipairs({ "enemy", "player" }) do + local mon = session[side] + if mon.rig and mon.visible and mon.model_matrix then + Stadium.guard(side, mon, "draw", function() + mon.rig:draw(mon.model_matrix, pull) + end) + end + end +end + +-- The same models as the SUN sees them, so a Pokemon throws the shadow of +-- the pose it is actually in -- an outstretched wing puts an outstretched +-- wing on the ground. +function Stadium.cast(shadowMap) + if not session then return end + for _, side in ipairs({ "enemy", "player" }) do + local mon = session[side] + if mon.rig and mon.visible and mon.model_matrix then + Stadium.guard(side, mon, "cast", function() + mon.rig:caster(shadowMap, mon.model_matrix) + end) + end + end +end + +-- Which state a side's model is playing, or nil. Named for the shot drivers: +-- checking that an animation starts on the right FRAME is an ordering +-- question, and a screenshot cannot answer one. +function Stadium.animOf(side) + if not session then return nil end + local mon = session[side] + return mon and mon.state or nil +end + +-- Whether this side's model is actually being drawn this frame. Named for +-- the shot drivers alongside animOf: "how long does it stay" is a span, and +-- a screenshot taken at one moment has no span in it. +function Stadium.showing(side) + if not session then return false end + local mon = session[side] + return (mon and mon.visible) and true or false +end + +-- How big this side's model is being drawn this frame, 0..1 -- the send-out +-- grow. Named for the shot drivers: a ramp is a curve over time and a +-- screenshot has one point of it. +function Stadium.scaleOf(side) + if not session then return nil end + local mon = session[side] + return mon and mon.scale or nil +end + +-- How wide the Pokemon on `side` stands, in world pixels, or nil when there +-- is not one. What STADIUM B sizes that side's platform to (StadiumStage). +function Stadium.footprint(side) + if not session then return nil end + local mon = session[side] + if not (mon and mon.model) then return nil end + local r = mon:worldRadius() + return (r > 0) and r or nil +end + +-- Whether anything at all is standing this frame -- what the shadow +-- signature keys on alongside the pics' own token. +function Stadium.standing() + if not session then return false end + return (session.player.visible or session.enemy.visible) and true or false +end + +-- ------- what the fight asks for +-- +-- The animation state machine is driven from four points in the engine's +-- own battle, and each is a wrap rather than a rewrite: the inner function +-- runs exactly as it always did and this reads what went past. + +local function sideOf(battle, battler) + if not (session and battler) then return nil end + if battler == battle.player then return "player" end + if battler == battle.enemy then return "enemy" end + return nil +end + +local function ask(battle, battler, state, animIndex, auxIndex) + local side = sideOf(battle, battler) + if not side then return end + local mon = session[side] + if mon and mon.rig then mon:request(state, animIndex, auxIndex) end +end + +function Stadium.install() + local BattleState = require("src.battle.BattleState") + if BattleState.dramaticShapeStadiumHook then return end + BattleState.dramaticShapeStadiumHook = true + + -- THE ATTACK. performMove is the one place a move is actually used, and + -- the move's own `index` is the Gen 1 move id the Stadium tables are + -- keyed by -- so the species' own animation for that move comes straight + -- out of the pack, with no name mapping and no per-move code. + local innerMove = BattleState.performMove + function BattleState:performMove(user, target, moveInst, isCalled) + if session then + local side = sideOf(self, user) + local mon = side and session[side] + if mon and mon.rig then + local okDef, def = pcall(self.moveDef, self, moveInst) + local index = okDef and def and def.index or nil + if not (index and mon:attack(index)) then + -- a move the table has nothing for still swings: the generic + -- attack is what the species' own reaction slot resolves to + mon:request("attack") + end + end + end + return innerMove(self, user, target, moveInst, isCalled) + end + + -- THE HIT is deliberately NOT hooked. There is no damage reaction in this + -- set to play -- what looked like one is the species' default attack (see + -- StadiumMon's STATES), which is why being hit used to look like swinging. + -- The engine's own flash, pic blink and bar drain are what say "that hurt", + -- and they are already in the frame. + + -- THE FAINT. Held on its last frame rather than looped (see StadiumMon's + -- STATES), because a Pokemon that collapses and then stands back up + -- while the message is still on screen is worse than no animation. + -- + -- RECORDED HERE, PLAYED LATER. This runs the moment HP reaches zero, which + -- is several seconds before the Pokemon is supposed to fall over -- the + -- engine queues the collapse behind the move animation and the HP-bar + -- drain. Marking the side and letting Stadium.update fire it when the bar + -- empties is what keeps the two together (see faintReady). + local innerFaint = BattleState.onFaint + function BattleState:onFaint(battler) + if session and not (battler and battler.faintQueued) then + local side = sideOf(self, battler) + if side and session.faintPending then + session.faintPending[side] = true + end + end + return innerFaint(self, battler) + end + + -- THE ENTRANCE. startGrowIn is the send-out: the ball opens, the pic + -- scales up over twelve frames, and the model plays the animation the + -- battle system's own entrance slot names. + local innerGrow = BattleState.startGrowIn + function BattleState:startGrowIn(battler) + if session then + -- unless the model is already on its way out of the ball, in which + -- case the entrance started with the POOF (see update) and asking + -- again here would restart it a third of a second in + local side = sideOf(self, battler) + local mon = side and session[side] + if not (mon and mon.grow) then ask(self, battler, "entrance") end + end + return innerGrow(self, battler) + end + + -- TRANSFORM. The engine records a transform by swapping the battler's + -- sprite and nothing else, so this is the only seam that reports one -- + -- and it reports the side, which is all that is needed to point that + -- side's model at the copied species. Cleared when a side's own species + -- changes under it (a switch, or the next battle). + local innerSpecies = BattleState.speciesSprite + function BattleState:speciesSprite(species, isPlayerSide) + if session then + session.transform[isPlayerSide and "player" or "enemy"] = dexOf(species) + end + return innerSpecies(self, species, isPlayerSide) + end + + -- and a switch or a send-out ends any transform on that side + local innerSwitch = BattleState.resolveSwitch + function BattleState:resolveSwitch(newMon) + if session then session.transform.player = nil end + return innerSwitch(self, newMon) + end +end + +-- ------- when a draw goes wrong +-- +-- The draw and the shadow cast are both called through a pcall, because a +-- throw inside the scene pass would hand the whole voxel mode to Pipelines' +-- guard and retire it for the session. Swallowed silently, though, a broken +-- model is indistinguishable from an invisible one -- so the first failure +-- of a battle says so, once, and the rest of the fight carries on without +-- it. +Stadium.reported = false + +function Stadium.report(err) + if Stadium.reported then return end + Stadium.reported = true + V.mod.log:warn("stadium: a model failed and was retired for this battle: " + .. "%s -- that Pokemon falls back to its flat battle pic, " + .. "and its opponent is unaffected", tostring(err)) +end + +-- DS_STADIUM_DEBUG=1 prints what each side resolved to once a second, which +-- is how "nothing is on screen" gets told apart from "nothing was asked +-- for". Read through pcall: the loader's sandbox does not hand a mod `os`, +-- and a diagnostic must never be why the mod fails to load. +local DEBUG = select(2, pcall(function() return os.getenv("DS_STADIUM_DEBUG") end)) +if DEBUG == nil or DEBUG == false then DEBUG = nil end + +local debugAt = 0 + +function Stadium.debug(dt) + if not (DEBUG and session) then return end + debugAt = debugAt + (dt or 0) + if debugAt < 1 then return end + debugAt = 0 + for _, side in ipairs({ "enemy", "player" }) do + local mon = session[side] + local m = mon.model_matrix + V.mod.log:info("stadium %s: dex=%s rig=%s visible=%s anim=%s t=%.2f " + .. "height=%.1f at=%s", + side, tostring(mon.species), tostring(mon.rig ~= nil), + tostring(mon.visible), tostring(mon.anim), mon.time or 0, + mon.model and mon:worldHeight() or 0, + m and ("%.0f,%.0f,%.0f"):format(m[4], m[8], m[12]) or "-") + end +end + +function Stadium.invalidate() + if session then + session.player:release() + session.enemy:release() + end + StadiumPack.invalidate() + -- the discs are a mesh and a texture like anything else, and a graphics + -- context that went away took them with it + pcall(function() V.require("StadiumStage").invalidate() end) +end + +return Stadium diff --git a/lib/StadiumBuild.lua b/lib/StadiumBuild.lua new file mode 100644 index 0000000..44a1fd5 --- /dev/null +++ b/lib/StadiumBuild.lua @@ -0,0 +1,710 @@ +-- STADIUM battles: turning the ROM into assets/stadium/NNN.dsm. +-- +-- The Lua half of tools/stadium_pack.py: measure the bind pose, decide +-- whether a species' standby loop can be trusted, and write the packed file. +-- Together with StadiumRom, StadiumFragment and StadiumFx this is everything +-- between `baserom.z64` and a Pokemon standing on a battle tile. +-- +-- The Python remains the ORACLE. tests/stadium_extract_test.lua runs this +-- over the same ROM and requires all 151 files to come out byte for byte +-- identical to what the packer writes. That is a strong test in a way a unit +-- test of any one function here would not be: every rounding mode, every +-- iteration order, every off-by-one in an index shows up as a differing byte, +-- and there are thirty-four megabytes of them. +-- +-- ------- stepped, not blocking +-- +-- `StadiumBuild.job()` returns a coroutine-backed job that does one species +-- per `step()`, so the caller can draw a progress bar between them +-- (StadiumInstall). A species is a few tens of milliseconds; the whole set is +-- around half a minute, which is far too long to spend inside one frame and +-- perfectly fine spread across a loading screen. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local StadiumRom = V.require("StadiumRom") +local StadiumFragment = V.require("StadiumFragment") +local StadiumFx = V.require("StadiumFx") + +local StadiumBuild = {} + +local floor = math.floor +local sin, cos = math.sin, math.cos +local pi = math.pi +local char = string.char +local concat = table.concat +local frexp = math.frexp +local roundHalfEven = StadiumFragment.roundHalfEven + +-- The battle system's fixed context slots, in slot order from 165. The mod +-- indexes this list by POSITION, so the ORDER is the format's contract and +-- has to stay identical to StadiumPack.CONTEXT and to the packer's CONTEXTS. +StadiumBuild.CONTEXTS = { + "idle", "attack_default", "faint", "entrance", "reaction_169", "reaction_170", + "reaction_171", "reaction_172", "reaction_173", "reaction_174", + "struggle", "idle_alt", "faint_alt", "flinch", "reaction_179", + "reaction_180", "reaction_181", "reaction_182", "entrance_alt", + "idle_return", +} + +-- Which context name wins when several claim the same animation. +local NAME_PREF = { "idle", "attack_default", "faint", "entrance", + "struggle", "flinch" } + +local N_MOVES = StadiumRom.N_MOVES +local CTX_BASE = 165 +local NONE16 = 0xFFFF + +-- ------- the bind pose + +-- The game's rotation as a 3x3, rows first (src/F420.c func_8000F730): +-- Rx*Ry*Rz in row-vector form. +local function quatBasis(r) + local sx, cx = sin(r[1] / 32768 * pi), cos(r[1] / 32768 * pi) + local sy, cy = sin(r[2] / 32768 * pi), cos(r[2] / 32768 * pi) + local sz, cz = sin(r[3] / 32768 * pi), cos(r[3] / 32768 * pi) + return { cy * cz, sx * sy * cz - cx * sz, cx * sy * cz + sx * sz }, + { cy * sz, sx * sy * sz + cx * cz, cx * sy * sz - sx * cz }, + { -sy, sx * cy, cx * cy } +end + +-- 3x4 (three rotation rows plus a translation column) times the same. +local function matMul(a, b) + local out = {} + for r = 1, 3 do + local ar = a[r] + out[r] = { + ar[1] * b[1][1] + ar[2] * b[2][1] + ar[3] * b[3][1], + ar[1] * b[1][2] + ar[2] * b[2][2] + ar[3] * b[3][2], + ar[1] * b[1][3] + ar[2] * b[2][3] + ar[3] * b[3][3], + ar[1] * b[1][4] + ar[2] * b[2][4] + ar[3] * b[3][4] + ar[4], + } + end + return out +end + +-- One component of one bone's t/r/s at a frame. The extractor's own shape: a +-- bare number when the component holds still for the whole animation, one +-- number a frame when it does not. +local function component(comps, i, frame, fallback) + if comps == nil then return fallback end + local c = comps[i] + if type(c) == "table" then + local n = #c + if n == 0 then return fallback end + return c[frame % n + 1] + end + return c +end + +-- The bone TRS an animation holds at `frame`, rest where it is silent. +local function animSample(bones, anim, frame) + local tracks = anim.tracks + return function(i) + local b = bones[i] + local tr = tracks[i] + if not tr then return b.t, b.r, b.s end + return { component(tr.t, 1, frame, b.t[1]), + component(tr.t, 2, frame, b.t[2]), + component(tr.t, 3, frame, b.t[3]) }, + { component(tr.r, 1, frame, b.r[1]), + component(tr.r, 2, frame, b.r[2]), + component(tr.r, 3, frame, b.r[3]) }, + { component(tr.s, 1, frame, b.s[1]), + component(tr.s, 2, frame, b.s[2]), + component(tr.s, 3, frame, b.s[3]) } + end +end + +local function restSample(bones) + return function(i) + local b = bones[i] + return b.t, b.r, b.s + end +end + +-- Every bone's draw matrix at one instant, as 3x4 rows. +-- +-- The game keeps bone scale OUT of the matrix chain: it accumulates in its own +-- stack, a bone's local translation is pre-multiplied by the PARENT's +-- accumulated scale, and the bone's own accumulated scale is applied to the +-- finished matrix at draw time. +-- +-- Two chains, and the distinction is the whole point: `pivot` is the +-- rotation/translation a CHILD inherits, and the draw matrix is that with the +-- bone's own accumulated scale applied on the right. Folding the scale into +-- the chain instead applies every ancestor's scale twice -- which is exactly +-- the multiplicative propagation glTF has and the game does not. +local function bindMatrices(bones, sample) + sample = sample or restSample(bones) + local pivot, draw, acc = {}, {}, {} + local IDENT = { { 1, 0, 0, 0 }, { 0, 1, 0, 0 }, { 0, 0, 1, 0 } } + for i = 1, #bones do + local bt, br, bs = sample(i) + local p = bones[i].parent + local pa = (p >= 0) and acc[p + 1] or { 1.0, 1.0, 1.0 } + local pm = (p >= 0) and pivot[p + 1] or IDENT + local r1, r2, r3 = quatBasis(br) + local m = matMul(pm, { + { r1[1], r1[2], r1[3], bt[1] * pa[1] }, + { r2[1], r2[2], r2[3], bt[2] * pa[2] }, + { r3[1], r3[2], r3[3], bt[3] * pa[3] }, + }) + local a = { pa[1] * bs[1], pa[2] * bs[2], pa[3] * bs[3] } + acc[i] = a + pivot[i] = m + -- scale on the right: the bone's own space, so it cannot reach children + draw[i] = { + { m[1][1] * a[1], m[1][2] * a[2], m[1][3] * a[3], m[1][4] }, + { m[2][1] * a[1], m[2][2] * a[2], m[2][3] * a[3], m[2][4] }, + { m[3][1] * a[1], m[3][2] * a[2], m[3][3] * a[3], m[3][4] }, + } + end + return draw +end + +StadiumBuild.bindMatrices = bindMatrices +StadiumBuild.animSample = animSample + +-- The axis-aligned box the whole model occupies under `mats`, in game units +-- after the model_root scale. +local function poseBox(data, mats) + local root = data.rootScale[1] + local lo1, lo2, lo3 = 1e30, 1e30, 1e30 + local hi1, hi2, hi3 = -1e30, -1e30, -1e30 + for _, prim in ipairs(data.prims) do + local pos, skin = prim.pos, prim.skin + for i = 1, prim.nverts do + local m = mats[skin[i] + 1] + if m then + local x, y, z = pos[i * 3 - 2], pos[i * 3 - 1], pos[i * 3] + local a = (m[1][1] * x + m[1][2] * y + m[1][3] * z + m[1][4]) * root + local b = (m[2][1] * x + m[2][2] * y + m[2][3] * z + m[2][4]) * root + local c = (m[3][1] * x + m[3][2] * y + m[3][3] * z + m[3][4]) * root + if a < lo1 then lo1 = a end + if b < lo2 then lo2 = b end + if c < lo3 then lo3 = c end + if a > hi1 then hi1 = a end + if b > hi2 then hi2 = b end + if c > hi3 then hi3 = c end + end + end + end + return lo1, lo2, lo3, hi1, hi2, hi3 +end + +-- (height, floor, radius): how tall the mon is, where its lowest point sits +-- relative to the model's own origin, and how wide it is -- all in game units +-- after the model_root scale. +-- +-- Measured on the BIND POSE, which is the one pose in the set that can be +-- trusted for this. It reproduces the verified glTF export exactly on all 151 +-- species, and it is immune to the animation quirks a handful of them carry +-- (see idleIsBroken) -- quirks that would otherwise decide how big every OTHER +-- frame of those species is drawn. +-- +-- The floor is the interesting number, and it reads cleanly: 119 of the 151 +-- sit within 5% of zero, which says the model origin IS where the game stands +-- a Pokemon on its field. Every species that does not is one that hovers. +local function stance(data) + local lo1, lo2, lo3, hi1, hi2, hi3 = poseBox(data, bindMatrices(data.bones)) + if lo1 > hi1 then return 0.0, 0.0, 0.0 end + local w, d = hi1 - lo1, hi3 - lo3 + return hi2 - lo2, lo2, (w > d and w or d) / 2 +end + +StadiumBuild.stance = stance + +-- Whether this species' standby loop is corrupt in the source data. +-- +-- A handful come out of the extraction with animations that throw bones +-- hundreds of units off the body -- Exeggutor, Tangela and Magmar, whose +-- channel streams the game's own index arithmetic evidently reads differently +-- from the way this does. Played, they look like a Pokemon coming apart; the +-- mod would rather stand them still. +-- +-- The test is deliberately narrow, because "differs from the bind pose" is NOT +-- brokenness. It is asked only of the STANDBY loop -- the one animation that +-- is supposed to stay where it is, since a faint is meant to end far from the +-- standing pose and an attack is meant to lunge -- and it wants both a large +-- size blow-up and real drift, or an enormous amount of one. Dewgong is what +-- calibrates it: its idle is 2.4x its own bind pose because the BIND is the +-- collapsed one, and it drifts barely at all, so it must not be caught. +local function idleIsBroken(data, idle) + if idle == nil then return false end + local bones = data.bones + local _, lo2, _, _, hi2 = poseBox(data, bindMatrices(bones)) + local span = hi2 - lo2 + if span <= 0 then return false end + local worstH, worstDrift = 1.0, 0.0 + local frame = 0 + while frame < idle.frames do + local _, flo2, _, _, fhi2 = poseBox(data, + bindMatrices(bones, animSample(bones, idle, frame))) + local h = (fhi2 - flo2) / span + if h > worstH then worstH = h end + local d1 = (flo2 - lo2) / span + local d2 = (fhi2 - hi2) / span + if d1 < 0 then d1 = -d1 end + if d2 < 0 then d2 = -d2 end + if d1 > worstDrift then worstDrift = d1 end + if d2 > worstDrift then worstDrift = d2 end + frame = frame + 3 + end + return (worstH > 2.5 and worstDrift > 1.5) + or worstDrift > 2.0 or worstH > 3.4 +end + +-- ------- writing + +local function clamp(v, lo, hi) + if v < lo then return lo end + if v > hi then return hi end + return v +end + +-- Toward zero, which is what Python's int() does to a float and NOT what +-- floor() does to a negative one. +-- +-- It matters in exactly one place, and it is easy to miss: almost everything +-- reaching the integer writers below has already been rounded, so truncation +-- is a no-op on it. The exception is the generated effects' crossed quads +-- (StadiumFx), whose vertices are raw floats and straddle the origin -- so +-- the ones at negative x, and only those, come out a unit adrift if this +-- floors. +local function trunc(v) + if v >= 0 then return floor(v) end + return -floor(-v) +end + +-- 16.16, which holds every bone scale in the set (-31 .. 100) with more +-- precision than anything can see. +local function fixed(v) + return clamp(roundHalfEven(v * 65536), -2147483648, 2147483647) +end + +-- IEEE 754 single, little-endian, rounded to nearest with ties to even -- the +-- same rounding Python's struct.pack('= 255 then + return char(0, 0, 128, 127 + sign) -- overflow + elseif E <= 0 then + -- subnormal: no exponent left, so the mantissa carries the whole value + mant = roundHalfEven(x / 2 ^ -149) + if mant >= 8388608 then + mant, E = mant - 8388608, 1 + else + E = 0 + end + else + mant = roundHalfEven((m * 2 - 1) * 8388608) + if mant == 8388608 then -- rounded up into the next + mant, E = 0, E + 1 + if E >= 255 then return char(0, 0, 128, 127 + sign) end + end + end + local b4 = sign + floor(E / 2) + local b3 = (E % 2) * 128 + floor(mant / 65536) + local b2 = floor(mant / 256) % 256 + local b1 = mant % 256 + return char(b1, b2, b3, b4) +end + +StadiumBuild.f32 = f32 + +local Writer = {} +Writer.__index = Writer + +local function newWriter() + return setmetatable({ parts = {}, n = 0 }, Writer) +end + +function Writer:raw(s) + self.n = self.n + 1 + self.parts[self.n] = s +end + +function Writer:u8(v) + self:raw(char(v % 256)) +end + +function Writer:i8(v) + v = clamp(trunc(v), -128, 127) + self:raw(char(v % 256)) +end + +function Writer:u16(v) + v = v % 65536 + self:raw(char(v % 256, floor(v / 256))) +end + +function Writer:i16(v) + v = clamp(trunc(v), -32768, 32767) % 65536 + self:raw(char(v % 256, floor(v / 256))) +end + +function Writer:u32(v) + v = v % 4294967296 + self:raw(char(v % 256, floor(v / 256) % 256, floor(v / 65536) % 256, + floor(v / 16777216) % 256)) +end + +function Writer:i32(v) + v = clamp(trunc(v), -2147483648, 2147483647) % 4294967296 + self:raw(char(v % 256, floor(v / 256) % 256, floor(v / 65536) % 256, + floor(v / 16777216) % 256)) +end + +function Writer:f32(v) + self:raw(f32(v)) +end + +function Writer:bytes() + return concat(self.parts) +end + +-- One component of one bone's t/r/s in one animation. `values` is the +-- extractor's own shape: a bare number when the component holds still for the +-- whole animation, or one number a frame when it does not. That fold is where +-- most of the size saving is -- a bone that only rotates costs two bytes for +-- each of its six other components. +local function writeTrackComponent(w, values, kind) + local isArray = type(values) == "table" + w:u8(isArray and 1 or 0) + if kind == "s" then + if isArray then + for i = 1, #values do w:i32(fixed(values[i])) end + else + w:i32(fixed(values)) + end + else + if isArray then + for i = 1, #values do w:i16(roundHalfEven(values[i])) end + else + w:i16(roundHalfEven(values)) + end + end +end + +-- Which animation each fixed battle context slot resolves to: entries 165 +-- upward of the species' own battle table, in slot order. An entry naming an +-- animation the species does not have is written as "none" rather than +-- clamped -- the mod would rather fall back than play the wrong clip. +function StadiumBuild.contextTable(rows, nAnims) + local ctx = {} + for i = 1, #StadiumBuild.CONTEXTS do + local row = rows[CTX_BASE + i - 1] + local ai = row and row[1] or nil + ctx[i] = (ai ~= nil and ai < nAnims) and ai or NONE16 + end + return ctx +end + +-- ------- naming the animations +-- +-- build.py's label_animations. The names are not read at runtime -- the mod +-- addresses animations by index through the move and context tables -- but +-- they are in the format, so they have to be produced the same way for the +-- oracle diff to mean anything. They also make a packed file readable in a +-- hex dump, which is worth the byte apiece. + +local function labelAnimations(data, rows, nAux) + local anims = data.anims + local n = #anims + local uses, moveUses = {}, {} + local auxOrder, auxCount = {}, {} + for i = 1, n do + uses[i], moveUses[i] = {}, 0 + auxOrder[i], auxCount[i] = {}, {} + end + for e = 0, rows.n - 1 do + local ai = rows[e][1] + if ai < n then + if e < N_MOVES then + moveUses[ai + 1] = moveUses[ai + 1] + 1 + elseif e >= CTX_BASE and e < CTX_BASE + #StadiumBuild.CONTEXTS then + local list = uses[ai + 1] + list[#list + 1] = StadiumBuild.CONTEXTS[e - CTX_BASE + 1] + end + local ax = rows[e][2] + if ax >= 0 and ax < nAux then + local counts, order = auxCount[ai + 1], auxOrder[ai + 1] + if counts[ax] == nil then + counts[ax] = 0 + order[#order + 1] = ax + end + counts[ax] = counts[ax] + 1 + end + end + end + + for i = 1, n do + -- sorted(set(uses)) -- the alphabetically first context is the fallback + -- name, so the ordering is part of the answer + local seen, ctx = {}, {} + for _, name in ipairs(uses[i]) do + if not seen[name] then + seen[name] = true + ctx[#ctx + 1] = name + end + end + table.sort(ctx) + local name = nil + for _, pref in ipairs(NAME_PREF) do + if seen[pref] then + name = pref + break + end + end + if not name then + if moveUses[i] > 0 then + name = "attack" + elseif ctx[1] then + name = ctx[1] + else + name = "anim" .. (i - 1) + end + end + anims[i].name = name + -- Counter.most_common(1): the highest count, and on a tie the one that + -- was inserted first + local best, bestN = -1, -1 + local order, counts = auxOrder[i], auxCount[i] + for _, ax in ipairs(order) do + if counts[ax] > bestN then + best, bestN = ax, counts[ax] + end + end + anims[i].aux = best + end + + local seenName = {} + for i = 1, n do + local base = anims[i].name + local k = seenName[base] or 0 + seenName[base] = k + 1 + if k > 0 then anims[i].name = base .. "_" .. (k + 1) end + end +end + +-- ------- the pack + +function StadiumBuild.pack(data, species, moveRows, ctx) + local w = newWriter() + local bones, prims = data.bones, data.prims + local textures, anims, aux = data.textures, data.anims, data.auxAnims + + local height, floorY, radius = stance(data) + + local idleIndex = ctx[1] -- CONTEXTS[1] is "idle" + local idle = (idleIndex ~= NONE16) and anims[idleIndex + 1] or nil + local static = idleIsBroken(data, idle) + + w:raw("DSM3") + w:u16(species) + w:u16(#bones) + w:u16(#prims) + w:u16(#textures) + w:u16(#anims) + w:u16(#aux) + w:f32(data.rootScale[1]) + -- 1 = hold the bind pose, never play an animation + w:u8(static and 1 or 0) + w:f32(height) + w:f32(floorY) + w:f32(radius) + + for m = 1, N_MOVES do + local row = moveRows[m] + w:u16((row and row[1] < #anims) and row[1] or NONE16) + end + for m = 1, N_MOVES do + local row = moveRows[m] + w:i16((row and row[2] >= 0 and row[2] < #aux) and row[2] or -1) + end + for i = 1, #ctx do w:u16(ctx[i]) end + + for i = 1, #bones do + local b = bones[i] + w:i16(b.parent) + for k = 1, 3 do w:i16(roundHalfEven(b.t[k])) end + for k = 1, 3 do w:i16(b.r[k]) end + for k = 1, 3 do w:i32(fixed(b.s[k])) end + end + + for i = 1, #prims do + local p = prims[i] + w:u16(p.tex) + -- the display list's own cull mode: 1024 is G_CULL_BACK + w:u8((p.cull and p.cull ~= 0) and 1 or 0) + w:u8((p.blend == "add") and 1 or 0) + w:i16(p.texAnim or -1) + -- sorted by the stream's own byte, which is what the reader keys on + local keys = {} + if p.texMap then + for k in pairs(p.texMap) do keys[#keys + 1] = k end + table.sort(keys) + end + w:u8(#keys) + for _, k in ipairs(keys) do + w:u8(k) + w:u16(p.texMap[k]) + end + local frames = p.fxFrames + w:u16(frames and #frames or 0) + if frames then + for k = 1, #frames do w:u16(frames[k]) end + end + local pos, uv, nrm, skin = p.pos, p.uv, p.nrm, p.skin + w:u16(p.nverts) + w:u16(p.nidx) + for k = 1, p.nverts do + w:i16(pos[k * 3 - 2]) + w:i16(pos[k * 3 - 1]) + w:i16(pos[k * 3]) + -- 1/512, which puts a texel of the largest texture in the set well + -- inside a step and still reaches the +-32 the wrapped coordinates of + -- some display lists run to + w:i16(roundHalfEven(uv[k * 2 - 1] * 512)) + w:i16(roundHalfEven(uv[k * 2] * 512)) + w:i8(roundHalfEven(nrm[k * 3 - 2] * 127)) + w:i8(roundHalfEven(nrm[k * 3 - 1] * 127)) + w:i8(roundHalfEven(nrm[k * 3] * 127)) + w:u8(skin[k]) + end + for k = 1, p.nidx do w:u16(p.idx[k]) end + end + + for i = 1, #textures do + local t = textures[i] + w:u16(t.w) + w:u16(t.h) + w:u32(#t.rgba) + w:raw(t.rgba) + end + + local REST = { t = { 0, 0, 0 }, r = { 0, 0, 0 }, s = { 1.0, 1.0, 1.0 } } + for i = 1, #anims do + local a = anims[i] + local name = a.name or "" + if #name > 255 then name = name:sub(1, 255) end + w:u8(#name) + w:raw(name) + w:u16(a.frames) + w:u16(a.loopStart or 0) + w:i16(a.aux or -1) + for bi = 1, #bones do + local tr = a.tracks[bi] + if not tr then + w:u8(0) + else + w:u8(1) + for _, key in ipairs({ "t", "r", "s" }) do + local comps = tr[key] + if comps == nil then + -- a bone the animation leaves at its rest value for this path: + -- written as three constants so the reader never has to branch on + -- a missing path + comps = bones[bi][key] or REST[key] + end + for c = 1, 3 do writeTrackComponent(w, comps[c], key) end + end + end + end + end + + for i = 1, #aux do + local a = aux[i] + w:u16(a.frames) + w:u16(a.loopStart or 0) + w:u16(#a.channels) + for _, ch in ipairs(a.channels) do + w:u16(ch.n) + for k = 1, ch.n do w:u16(ch[k]) end + end + end + + return w:bytes(), height, floorY, radius +end + +-- ------- one species, end to end + +-- The same three steps build.py takes: parse the fragment, label the +-- animations off the species' battle table, then hang the generated fire/gas +-- stand-ins on the bones the game's own effect callbacks hang off. +function StadiumBuild.species(rom, fileno) + local blob = rom:model(fileno) + if not blob then return nil, ("file %d is not in the archive"):format(fileno) end + local data, err = StadiumFragment.extract(blob, ("%d.bin"):format(fileno)) + if not data then return nil, err end + local species = data.species + local rows = rom:battleRows(species) + labelAnimations(data, rows, #data.auxAnims) + StadiumFx.attach(data, species) + + local moveRows = {} + for m = 1, N_MOVES do moveRows[m] = rows[m - 1] end + local ctx = StadiumBuild.contextTable(rows, #data.anims) + local bytes, height, floorY, radius = + StadiumBuild.pack(data, species, moveRows, ctx) + return { species = species, bytes = bytes, height = height, + floor = floorY, radius = radius, bones = #data.bones, + prims = #data.prims, anims = #data.anims, + warnings = data.warnings } +end + +-- ------- the stepped job +-- +-- `write(species, bytes)` is called for each finished pack and must answer +-- truthy; anything else stops the job with an error. Returning a job rather +-- than taking a callback for progress keeps the caller in charge of when work +-- happens, which is what lets a loading screen stay responsive. +function StadiumBuild.job(rom, write, count) + local total = count or StadiumRom.N_POKEMON + local n = rom:modelCount() + if total > n then total = n end + local job = { total = total, done = 0, bytes = 0, failed = {}, species = nil } + + function job:step() + if self.done >= self.total then return false end + local fileno = self.done + local ok, res, err = pcall(StadiumBuild.species, rom, fileno) + if ok and res then + local wrote, wErr = write(res.species, res.bytes) + if not wrote then + self.error = wErr or ("could not write species " .. res.species) + self.done = self.total + return false + end + self.bytes = self.bytes + #res.bytes + self.species = res.species + else + self.failed[#self.failed + 1] = fileno + self.lastError = ok and err or res + end + self.done = self.done + 1 + return self.done < self.total + end + + function job:progress() + if self.total <= 0 then return 1 end + return self.done / self.total + end + + return job +end + +return StadiumBuild diff --git a/lib/StadiumFragment.lua b/lib/StadiumFragment.lua new file mode 100644 index 0000000..9cf3565 --- /dev/null +++ b/lib/StadiumFragment.lua @@ -0,0 +1,1016 @@ +-- STADIUM battles: one FRAGMENT module -> geometry, textures, skeleton, +-- animations. +-- +-- A port of model_extract/pipeline/fragment.py, structure for structure and +-- name for name, so the two can be read side by side and diffed. The Python +-- is the reference implementation and stays the ORACLE: +-- tools/stadium_pack.py drives it over the same ROM and +-- tests/stadium_extract_test.lua requires the finished .dsm files to agree +-- byte for byte. That is the only way a port of this much numeric code can +-- be trusted -- reading it twice is not enough. +-- +-- ------- the three things it does +-- +-- THE GEO LAYOUT is a little bytecode: a tree of nodes with an open/close +-- stack, where some nodes are bones, some set the current material, and some +-- hand a display list to whichever bone is live. Walking it builds the +-- skeleton and decides which triangles belong to which bone. +-- +-- THE DISPLAY LISTS are F3DEX2, the N64's own graphics microcode. Only four +-- of its commands matter here -- load vertices, draw triangle(s), set the +-- geometry mode (which carries the cull bits), call/branch -- because +-- everything else is render state this rebuilds from the texture table +-- instead. +-- +-- THE ANIMATIONS are packed per-frame streams of 12- or 16-bit fields. The +-- game also has a hermite-keyframe mode (flags & 8); it is ported for +-- completeness but no animation of any of the 151 battle Pokemon uses it. +-- +-- ------- arithmetic, and why there are no bit operations here +-- +-- Every shift and mask is written as multiply/divide/modulo. Two reasons. +-- LuaJIT's `bit` library works on SIGNED 32-bit integers, so every value that +-- crosses 0x80000000 -- which pointers in this file constantly do -- would +-- need a conversion back, and each of those is a place to be wrong. And the +-- one shift that genuinely needs care (`bitfield` below) has to be exact to +-- 32 bits, which a double can do only if the value is reduced BEFORE it is +-- shifted up. Doing it in arithmetic makes that reduction visible instead of +-- hiding it behind an operator that silently truncates. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local StadiumFragment = {} + +local byte = string.byte +local char = string.char +local sub = string.sub +local concat = table.concat +local floor = math.floor + +-- Where the module is linked to run. Every pointer inside it is an address in +-- that space, so subtracting this turns one into a file offset. +local BASE = 0x8FF00000 + +-- Size of each geo-layout command, by opcode. A command this does not +-- understand is still stepped over correctly, which is what lets an unknown +-- one be a warning rather than the end of the walk. +local CMD_SIZES = { + [0x00] = 0x08, [0x01] = 0x04, [0x02] = 0x08, [0x03] = 0x08, [0x04] = 0x04, + [0x05] = 0x04, [0x06] = 0x04, [0x07] = 0x08, [0x08] = 0x0C, [0x09] = 0x04, + [0x0A] = 0x08, [0x0B] = 0x18, [0x0C] = 0x04, [0x0D] = 0x04, [0x0E] = 0x04, + [0x0F] = 0x04, [0x10] = 0x04, [0x11] = 0x04, [0x12] = 0x04, [0x13] = 0x08, + [0x14] = 0x0C, [0x15] = 0x0C, [0x16] = 0x04, [0x17] = 0x14, [0x18] = 0x08, + [0x19] = 0x08, [0x1A] = 0x04, [0x1B] = 0x10, [0x1C] = 0x10, [0x1D] = 0x1C, + [0x1E] = 0x08, [0x1F] = 0x18, [0x20] = 0x14, [0x21] = 0x10, [0x22] = 0x08, + [0x23] = 0x10, [0x24] = 0x04, [0x25] = 0x04, [0x26] = 0x14, +} + +StadiumFragment.CMD_SIZES = CMD_SIZES + +-- ------- rounding +-- +-- Python rounds half to EVEN, and the packer's numbers go through `round` +-- three times over (fold a track to a constant, quantise a scale to 16.16, +-- quantise a UV to 1/512). Rounding half away from zero instead would put +-- this one off by one wherever a value lands exactly on a boundary, and a +-- single byte is all it takes for the oracle diff to fail -- so it is worth +-- the six lines to do it the same way. + +local function roundHalfEven(x) + local f = floor(x) + local d = x - f + if d > 0.5 then return f + 1 end + if d < 0.5 then return f end + if f % 2 == 0 then return f end + return f + 1 +end + +local function roundTo(x, nd) + if nd == 0 then return roundHalfEven(x) end + local m = 10 ^ nd + return roundHalfEven(x * m) / m +end + +StadiumFragment.roundHalfEven = roundHalfEven +StadiumFragment.roundTo = roundTo + +-- Two's-complement reading of an n-bit field. +local function signed(v, bits) + local m = 2 ^ (bits - 1) + if v >= m then return v - m * 2 end + return v +end + +StadiumFragment.signed = signed + +-- The distinct values of an array, in the order they first appear. The Python +-- reached for a set here and that decided the order textures were registered +-- in -- see fragment.py's own `unique`. +local function unique(seq, n) + local seen, out, m = {}, {}, 0 + for i = 1, n or #seq do + local v = seq[i] + if v ~= nil and not seen[v] then + seen[v] = true + m = m + 1 + out[m] = v + end + end + return out, m +end + +-- ------- the module + +local Frag = {} +Frag.__index = Frag + +function Frag:u8(o) + return byte(self.d, o + 1) +end + +function Frag:s8(o) + local v = byte(self.d, o + 1) + if v >= 128 then return v - 256 end + return v +end + +function Frag:u16(o) + local a, b = byte(self.d, o + 1, o + 2) + return a * 256 + b +end + +function Frag:s16(o) + local a, b = byte(self.d, o + 1, o + 2) + local v = a * 256 + b + if v >= 32768 then return v - 65536 end + return v +end + +function Frag:u32(o) + local a, b, c, d = byte(self.d, o + 1, o + 4) + return ((a * 256 + b) * 256 + c) * 256 + d +end + +function Frag:s32(o) + local v = self:u32(o) + if v >= 2147483648 then return v - 4294967296 end + return v +end + +-- A pointer as a file offset, or nil for the null pointer. +function Frag:off(ptr) + if ptr == 0 then return nil end + return ptr - BASE +end + +function Frag:ptr(o) + return self:off(self:u32(o)) +end + +-- The entry stub ends with `lui rX, hi; addiu rX, rX, lo`, which is MIPS for +-- "load the address of the root struct". Finding that pair is how the root is +-- located without a symbol table. +function Frag:root() + for o = 0x20, 0x7C, 4 do + local w = self:u32(o) + if floor(w / 0x4000000) == 0x0F then -- lui + local reg = floor(w / 0x10000) % 0x20 + local w2 = self:u32(o + 4) + if floor(w2 / 0x4000000) == 0x09 -- addiu rX, rX, imm + and floor(w2 / 0x200000) % 0x20 == reg then + return self:u16(o + 2) * 65536 + self:s16(o + 6) - BASE + end + end + end + return nil +end + +-- A null-terminated run of pointers. +function Frag:ptrList(o) + local out, n = {}, 0 + while true do + local p = self:ptr(o) + if p == nil then return out, n end + n = n + 1 + out[n] = p + o = o + 4 + end +end + +-- `data` is one decompressed archive entry. +function StadiumFragment.open(data, name) + if type(data) ~= "string" or #data < 0x20 then + return nil, (name or "?") .. ": too short to be a module" + end + if sub(data, 9, 16) ~= "FRAGMENT" then + return nil, (name or "?") .. ": not a FRAGMENT module" + end + return setmetatable({ d = data, name = name or "" }, Frag) +end + +-- ------- walking the geo layout and running the display lists + +local Model = {} +Model.__index = Model + +local function newModel(frag) + local r = frag:root() + if not r then return nil, frag.name .. ": could not locate root struct" end + local geo = frag:ptr(r + 0x08) + local anims = frag:ptr(r + 0x0C) + local aux = frag:ptr(r + 0x10) + local m = setmetatable({ + f = frag, + species = frag:u16(r), + geoLayouts = geo and frag:ptrList(geo) or {}, + anims = anims and frag:ptrList(anims) or {}, + auxAnims = aux and frag:ptrList(aux) or {}, + textures = {}, -- { fmt, siz, w, h, texels, data } + tluts = {}, -- palettes: { count, data, dl } + bones = {}, -- { parent, boneId, flags, chan, t, r, s } + boneById = {}, + prims = {}, -- { tex, tlut, mat, texAnim, cull, verts, tris } + primsByKey = {}, + rootScale = { 1.0, 1.0, 1.0 }, + fx = {}, -- geo cmd 0x08 procedural effect nodes + warnings = {}, + }, Model) + return m +end + +function Model:readTextureTable(off, count) + local f = self.f + local t = self.textures + for i = 0, count - 1 do + local o = off + i * 0xC + t[#t + 1] = { + fmt = f:u8(o), siz = f:u8(o + 1), w = f:s16(o + 2), + h = f:u16(o + 4), texels = f:u16(o + 6), data = f:ptr(o + 8), + } + end +end + +-- Palettes reuse the texture-record layout: the count sits in the width +-- field, the palette data in the next word, and unk_08 is the display list +-- that loads it (src/12D80.c func_80015B20). The list is authoritative, so it +-- is run rather than trusted. +function Model:readTlutTable(off, count) + local f = self.f + for i = 0, count - 1 do + local o = off + i * 0xC + local rec = { count = f:u16(o + 2), data = f:ptr(o + 4), dl = f:ptr(o + 8) } + local dl = rec.dl + if dl ~= nil then + for _ = 1, 16 do + local w0, w1 = f:u32(dl), f:u32(dl + 4) + local op = floor(w0 / 0x1000000) + if op == 0xFD then -- G_SETTIMG + rec.data = f:off(w1) + elseif op == 0xF0 then -- G_LOADTLUT + rec.count = floor(w1 / 0x4000) % 0x400 + 1 + elseif op == 0xDF then + break + end + dl = dl + 8 + end + end + self.tluts[#self.tluts + 1] = rec + end +end + +-- The bone whose matrix is live. Mirrors gCurGraphNodeList in +-- src/geo_layout.c: the top of the stack is the slot the NEXT node command +-- writes to, so the node that owns the current one is the slot below +-- (func_80017AC4). +function Model:curBone() + local n = #self.stack + if n >= 2 then return self.stack[n - 1] end + return -1 +end + +function Model:build() + self.curTex = -1 + self.curTlut = -1 + self.curMat = nil + self.curTexAnim = -1 + self.stack = { -1 } + -- The RSP vertex cache persists ACROSS display lists: a bone's list often + -- preloads vertices that the next bone's list then indexes, which is how + -- these models get blended joints. Each slot remembers the bone whose + -- matrix was current when it was loaded. + self.vbuf = {} + if not self.geoLayouts[1] then + self.warnings[#self.warnings + 1] = "no geo layout" + return + end + self:walk(self.geoLayouts[1], 0) +end + +function Model:walk(o, depth) + local f = self.f + if depth > 32 or o == nil then return end + while true do + local cmd = f:u8(o) + local size = CMD_SIZES[cmd] + if size == nil then + self.warnings[#self.warnings + 1] = + ("unknown geo cmd 0x%02x at 0x%x"):format(cmd or -1, o) + return + end + if cmd == 0x01 or cmd == 0x04 then -- end / return + return + elseif cmd == 0x00 or cmd == 0x03 then -- branch (with return) + self:walk(f:ptr(o + 4), depth + 1) + elseif cmd == 0x02 then -- jump (no return) + o = f:ptr(o + 4) + if o == nil then return end + cmd = nil -- skip the o = o + size + elseif cmd == 0x05 then -- open node + local n = #self.stack + self.stack[n + 1] = self.stack[n] + elseif cmd == 0x06 then -- close node + self.stack[#self.stack] = nil + elseif cmd == 0x17 then -- model header + self:readTextureTable(f:ptr(o + 8), f:s16(o + 2)) + if f:ptr(o + 0xC) then + self:readTlutTable(f:ptr(o + 0xC), f:s16(o + 4)) + end + self.vtxBase = f:ptr(o + 0x10) + self.nVerts = f:s16(o + 6) + elseif cmd == 0x08 then -- effect callback + self.fx[#self.fx + 1] = { bone = self:curBone(), callback = f:u32(o + 4), + arg = f:ptr(o + 8) } + elseif cmd == 0x1C then -- uniform scale node + self.rootScale = { f:s32(o + 4) / 65536.0, f:s32(o + 8) / 65536.0, + f:s32(o + 0xC) / 65536.0 } + elseif cmd == 0x1D then -- bone / joint node + local idx = #self.bones -- 0-based, as the file + self.bones[idx + 1] = { + parent = self:curBone(), boneId = f:u8(o + 1), flags = f:u8(o + 2), + chan = f:s8(o + 3), + t = { f:s16(o + 4), f:s16(o + 6), f:s16(o + 8) }, + r = { f:s16(o + 0xA), f:s16(o + 0xC), f:s16(o + 0xE) }, + s = { f:s32(o + 0x10) / 65536.0, f:s32(o + 0x14) / 65536.0, + f:s32(o + 0x18) / 65536.0 }, + } + self.boneById[f:u8(o + 1)] = idx + self.stack[#self.stack] = idx + elseif cmd == 0x23 then -- set texture / material + self.curTex = f:s16(o + 8) + self.curTlut = f:s16(o + 0xA) + self.curMat = f:ptr(o + 4) + -- offset 0x02 is the texture-animation channel; -1 means static. + -- func_800176DC swaps this material's texture per frame out of the + -- auxiliary animation's stream. + self.curTexAnim = f:s16(o + 2) + elseif cmd == 0x22 then -- DL on current bone + self:runDL(f:ptr(o + 4), self:curBone(), 0) + elseif cmd == 0x1E then -- DL on named bone + local named = self.boneById[f:s16(o + 2)] + self:runDL(f:ptr(o + 4), named or self:curBone(), 0) + elseif cmd == 0x20 or cmd == 0x21 then -- DL + own transform + self:runDL(f:ptr(o + (cmd == 0x20 and 0x10 or 0xC)), self:curBone(), 0) + end + if cmd ~= nil then o = o + size end + end +end + +function Model:primFor(tex, tlut, mat, texAnim, cull) + local key = tex .. "," .. tlut .. "," .. tostring(mat) .. "," + .. texAnim .. "," .. cull + local p = self.primsByKey[key] + if p == nil then + p = { tex = tex, tlut = tlut, mat = mat, texAnim = texAnim, cull = cull, + verts = {}, nverts = 0, tris = {}, ntris = 0, remap = {} } + self.primsByKey[key] = p + self.prims[#self.prims + 1] = p + end + return p +end + +-- Bitwise AND and OR over the low `bits` bits, in arithmetic. See the module +-- header for why there is no `bit` library in here. +local function band(a, b, bits) + local r, p = 0, 1 + for _ = 1, bits do + if a % 2 == 1 and b % 2 == 1 then r = r + p end + a, b, p = floor(a / 2), floor(b / 2), p * 2 + end + return r +end + +local function bor(a, b, bits) + local r, p = 0, 1 + for _ = 1, bits do + if a % 2 == 1 or b % 2 == 1 then r = r + p end + a, b, p = floor(a / 2), floor(b / 2), p * 2 + end + return r +end + +-- One triangle out of the vertex cache and into a prim, deduplicating +-- vertices as it goes. +-- +-- A file-level function rather than a closure inside runDL: it is called +-- once per triangle -- some two hundred thousand times over the set -- and a +-- closure there would allocate one per display-list command. +-- +-- A triangle naming a cache slot that was never loaded is DROPPED, not +-- faulted. That happens where a display list indexes past what its own G_VTX +-- filled, which the hardware would read as stale cache; there is nothing +-- meaningful to draw. +-- +-- It is abandoned MID-TRIANGLE, exactly where the missing corner is found, +-- and the corners already walked keep the slots they were just given in the +-- vertex list. That looks like a detail and is not: those vertices stay in +-- the prim, so they shift every later vertex's index by one. Checking all +-- three corners up front instead -- which is what a careful reader writes -- +-- silently produces a different, equally plausible vertex list, and every +-- index in the file after that point moves. +local function emit(prim, vbuf, flip, ia, ib, ic) + local remap, verts = prim.remap, prim.verts + local tri = { 0, 0, 0 } + for k = 1, 3 do + local v = vbuf[k == 1 and ia or (k == 2 and ib or ic)] + if v == nil then return end + local key = v[1] .. "," .. v[2] .. "," .. v[3] .. "," .. v[4] .. "," + .. v[5] .. "," .. v[6] .. "," .. v[7] .. "," .. v[8] + .. "," .. v[9] .. "," .. v[10] + local j = remap[key] + if j == nil then + j = prim.nverts -- 0-based, as the file + remap[key] = j + prim.nverts = j + 1 + verts[j + 1] = v + end + tri[k] = j + end + if flip then tri[1], tri[3] = tri[3], tri[1] end + prim.ntris = prim.ntris + 1 + prim.tris[prim.ntris] = tri +end + +function Model:runDL(o, bone, depth) + if o == nil or depth > 8 then return end + local f = self.f + local vbuf = self.vbuf + local cull = 0x400 + while true do + local w0, w1 = f:u32(o), f:u32(o + 4) + local op = floor(w0 / 0x1000000) + o = o + 8 + if op == 0xDF then -- G_ENDDL + return + elseif op == 0xDE then -- G_DL + self:runDL(f:off(w1), bone, depth + 1) + if floor(w0 / 0x10000) % 256 ~= 0 then -- branch, not call + return + end + elseif op == 0x01 then -- G_VTX + local n = floor(w0 / 0x1000) % 256 + local v0 = floor((w0 % 0x1000) / 2) - n + local a = f:off(w1) + if a then + for i = 0, n - 1 do + local p = a + i * 0x10 + local slot = v0 + i + if slot >= 0 and slot < 64 then + -- s and t are S10.5 and kept RAW here; the /32 happens once, on + -- the way out, so that the dedup key below compares integers + vbuf[slot] = { f:s16(p), f:s16(p + 2), f:s16(p + 4), + f:s16(p + 8), f:s16(p + 10), + f:s8(p + 12), f:s8(p + 13), f:s8(p + 14), + f:u8(p + 15), bone } + end + end + end + elseif op == 0xD9 then -- G_GEOMETRYMODE + -- `cull = (cull & (w0 & 0xFFFFFF)) | w1`. + -- + -- Only bits 9 and 10 -- G_CULL_FRONT and G_CULL_BACK -- are ever read + -- out of this, so the whole word is kept to eleven bits and the AND and + -- OR done over those. Bits above that cannot influence bits 9 and 10 + -- under either operator, so nothing is lost and the two loops that used + -- to run 24 times each now run 11. + cull = bor(band(cull, w0 % 0x800, 11), w1 % 0x800, 11) + elseif op == 0x05 or op == 0x06 then -- G_TRI1 / G_TRI2 + local prim = self:primFor(self.curTex, self.curTlut, self.curMat, + self.curTexAnim, cull % 0x800 - cull % 0x200) + local flip = (floor(cull / 0x200) % 2 == 1) + and (floor(cull / 0x400) % 2 == 0) + emit(prim, vbuf, flip, + floor(floor(w0 / 0x10000) % 256 / 2), + floor(floor(w0 / 0x100) % 256 / 2), + floor(w0 % 256 / 2)) + if op == 0x06 then + emit(prim, vbuf, flip, + floor(floor(w1 / 0x10000) % 256 / 2), + floor(floor(w1 / 0x100) % 256 / 2), + floor(w1 % 256 / 2)) + end + end + -- everything else (SETTILE / sync / ...) is render state this rebuilds + -- from the texture table instead, so it is skipped + end +end + +-- CI4 selects a 16-entry block of the palette through the render tile's +-- palette field; read it out of the material display list's last G_SETTILE. +function Model:tilePalette(mat) + if mat == nil then return 0 end + local f = self.f + local pal = 0 + for _ = 1, 16 do + local w0, w1 = f:u32(mat), f:u32(mat + 4) + local op = floor(w0 / 0x1000000) + if op == 0xF5 and floor(w1 / 0x1000000) % 8 == 0 then -- render tile + pal = floor(w1 / 0x100000) % 16 + elseif op == 0xDF then + break + end + mat = mat + 8 + end + return pal +end + +-- ------- animations + +-- src/F420.c func_80010500: a signed `bits`-wide field at bit index*bits. +-- +-- C integer division truncates toward zero and Lua's floor() does not, which +-- only differs for a NEGATIVE index -- and a negative index is exactly what an +-- empty channel produces, so the truncating form is the one that matches the +-- hardware. +-- +-- The shift is the part that needs care. `(v << rem) & 0xFFFFFFFF` on a +-- 32-bit v would want 63 bits of intermediate, and a double holds 53 exactly. +-- Masking FIRST -- dropping the bits the shift would push out anyway -- keeps +-- the product under 2^32 and therefore exact. +local function bitfield(f, base, index, bits) + local bitpos = index * bits + local word + if bitpos >= 0 then + word = floor(bitpos / 16) + else + word = -floor(-bitpos / 16) + end + local rem = bitpos - word * 16 + local o = base + word * 2 + local v = f:u16(o) * 65536 + f:u16(o + 2) + local shift = rem % 32 + local keep = 2 ^ (32 - shift) + v = (v % keep) * 2 ^ shift + return signed(floor(v / 2 ^ (32 - bits)), bits) +end + +StadiumFragment.bitfield = bitfield + +local Anim = {} +Anim.__index = Anim + +local function newAnim(frag, off) + return setmetatable({ + f = frag, off = off, + flags = frag:u8(off), + startFrame = frag:u16(off + 4), + loopStart = frag:u16(off + 6), + nChannels = frag:u16(off + 8), + nFrames = frag:u16(off + 0xA), + chanTable = frag:ptr(off + 0xC), + scaleData = frag:ptr(off + 0x10), + rotData = frag:ptr(off + 0x14), + transData = frag:ptr(off + 0x18), + }, Anim) +end + +function Anim:chan(i) + local o = self.chanTable + i * 0xA + local f = self.f + return { nScale = f:u8(o), nRot = f:u8(o + 1), nTrans = f:u8(o + 2), + interp = f:u8(o + 3), oScale = f:u16(o + 4), + oRot = f:u16(o + 6), oTrans = f:u16(o + 8) } +end + +-- Packed per-frame streams (flags & 8 == 0), which is what every animation of +-- every battle Pokemon actually uses. +-- +-- A count of 0 means the component has no stream at all. The game's own index +-- arithmetic (offset + count - 1) then runs off the front of the array -- for +-- Tangela's idle the scale "array" is two entries long and the computed index +-- is 99 -- so an empty channel is read as "keep the bind-pose value", which is +-- what the nil return means to the caller. +function Anim:transPacked(c, frame) + if c.nTrans == 0 then return nil end + local wide = floor(self.flags / 4) % 2 == 1 + local bits = wide and 16 or 12 + if c.nTrans == 1 then + if wide then return c.oTrans + 0.0 end + return floor(signed(c.oTrans * 16 % 65536, 16) / 16) + 0.0 + end + local i = c.oTrans + (frame < c.nTrans - 1 and frame or c.nTrans - 1) + return bitfield(self.f, self.transData, i, bits) + 0.0 +end + +function Anim:rotPacked(c, frame) + if c.nRot == 0 then return nil end + if c.nRot == 1 then return signed(c.oRot * 16 % 65536, 16) end + local i = c.oRot + (frame < c.nRot - 1 and frame or c.nRot - 1) + return signed(bitfield(self.f, self.rotData, i, 12) * 16 % 65536, 16) +end + +function Anim:scalePacked(c, frame) + if c.nScale == 0 then return nil end + if c.nScale == 1 then return c.oScale / 1000.0 end + local i = c.oScale + (frame < c.nScale - 1 and frame or c.nScale - 1) + return self.f:s16(self.scaleData + i * 2) / 1000.0 +end + +-- Hermite keyframes (flags & 8). Ported for completeness: no animation of any +-- of the 151 battle Pokemon sets that flag, which was measured rather than +-- assumed. +function Anim:hermite(base, n, frame, wide) + local f = self.f + local stride = wide and 8 or 6 + local function key(i) + local o = base + i * stride + return f:s16(o), f:s16(o + 2), f:s16(o + 4), + wide and f:s16(o + 6) or f:s16(o + 4) + end + local k0t, k0v = key(0) + if k0t >= frame then return k0v + 0.0 end + local lt, lv = key(n - 1) + if frame >= lt then return lv + 0.0 end + local i = 0 + while i < n - 2 do + if frame < (key(i + 1)) then break end + i = i + 1 + end + local at, av, ao, aw = key(i) + local bt, bv, bo = key(i + 1) + local x = (frame - at) / 30.0 + local y = 30.0 / (bt - at) + local x2, x3 = x * x, x * x * x + local y2, y3 = y * y, y * y * y + return av * (2 * x3 * y3 - 3 * x2 * y2 + 1) + + bv * (-2 * x3 * y3 + 3 * x2 * y2) + + (wide and aw or ao) * (x3 * y2 - 2 * x2 * y + x) + + bo * (x3 * y2 - x2 * y) +end + +function Anim:transKey(c, frame) + if c.nTrans < 2 then return signed(c.oTrans, 16) + 0.0 end + return self:hermite(self.transData + c.oTrans * 2, c.nTrans, frame, + c.interp % 2 == 1) +end + +function Anim:rotKey(c, frame) + local deg + if c.nRot < 2 then + deg = signed(c.oRot, 16) / 10.0 + else + deg = self:hermite(self.rotData + c.oRot * 2, c.nRot, frame, + floor(c.interp / 2) % 2 == 1) / 10.0 + end + deg = deg % 360.0 + return floor(deg / 360.0 * 65536.0) +end + +function Anim:scaleKey(c, frame) + if c.nScale < 2 then return signed(c.oScale, 16) / 100.0 end + return self:hermite(self.scaleData + c.oScale * 2, c.nScale, frame, + floor(c.interp / 4) % 2 == 1) / 100.0 +end + +-- (translation, rotation, scale) for one bone at one frame. Components whose +-- channel carries no data fall back to the bone's bind value. +function Anim:sampleTrs(chanIndex, frame, bt, br, bs) + local base = chanIndex * 3 + if base < 0 or base + 2 >= self.nChannels then return nil end + local c1, c2, c3 = self:chan(base), self:chan(base + 1), self:chan(base + 2) + local t, r, s = {}, {}, {} + if floor(self.flags / 8) % 2 == 1 then + t[1], t[2], t[3] = self:transKey(c1, frame), self:transKey(c2, frame), + self:transKey(c3, frame) + r[1], r[2], r[3] = self:rotKey(c1, frame), self:rotKey(c2, frame), + self:rotKey(c3, frame) + s[1], s[2], s[3] = self:scaleKey(c1, frame), self:scaleKey(c2, frame), + self:scaleKey(c3, frame) + else + t[1], t[2], t[3] = self:transPacked(c1, frame), self:transPacked(c2, frame), + self:transPacked(c3, frame) + r[1], r[2], r[3] = self:rotPacked(c1, frame), self:rotPacked(c2, frame), + self:rotPacked(c3, frame) + s[1], s[2], s[3] = self:scalePacked(c1, frame), + self:scalePacked(c2, frame), self:scalePacked(c3, frame) + end + for k = 1, 3 do + if t[k] == nil then t[k] = bt[k] end + if r[k] == nil then r[k] = br[k] end + if s[k] == nil then s[k] = bs[k] end + end + return t, r, s +end + +-- Texture animation (src/18140.c). Same header shape as a skeletal animation, +-- but each channel is a per-frame stream of texture-table indices that +-- func_800176DC substitutes into a material. +local Aux = {} +Aux.__index = Aux + +local function newAux(frag, off) + return setmetatable({ + f = frag, + flags = frag:u8(off), + startFrame = frag:u16(off + 4), + loopStart = frag:u16(off + 6), + nChannels = frag:u16(off + 8), + nFrames = frag:u16(off + 0xA), + chanTable = frag:ptr(off + 0xC), + data = frag:ptr(off + 0x10), + }, Aux) +end + +-- func_80017540: index into the stream, clamped to the channel's length. +function Aux:sample(chan, frame) + if chan < 0 or chan >= self.nChannels or self.chanTable == nil then + return nil + end + local o = self.chanTable + chan * 4 + local count, base = self.f:u16(o), self.f:u16(o + 2) + if count == 0 then return nil end + local i = base + (frame < count and frame or count - 1) + return self.f:u8(self.data + i) +end + +function Aux:track(chan) + local n = self.nFrames > 1 and self.nFrames or 1 + local out = {} + for i = 0, n - 1 do out[i + 1] = self:sample(chan, i) end + return out, n +end + +-- ------- textures + +local function rgba5551(p) + return floor(floor(p / 2048) % 32 * 255 / 31), + floor(floor(p / 64) % 32 * 255 / 31), + floor(floor(p / 2) % 32 * 255 / 31), + (p % 2 == 1) and 255 or 0 +end + +-- (w, h, RGBA8 string) for the N64 texture formats these models use. +local function decodeTexture(f, tex, tlut, palette) + local w, h, fmt, siz, addr = tex.w, tex.h, tex.fmt, tex.siz, tex.data + local n = w * h + if n <= 0 or addr == nil then + return w, h, string.rep("\255\0\255\255", n > 0 and n or 0) + end + local d = f.d + local out = {} + + local function nibble(i) + local v = byte(d, addr + floor(i / 2) + 1) + if i % 2 == 1 then return v % 16 end + return floor(v / 16) + end + + if fmt == 0 and siz == 2 then -- RGBA16 (5/5/5/1) + for i = 0, n - 1 do + local a, b = byte(d, addr + i * 2 + 1, addr + i * 2 + 2) + out[i + 1] = char(rgba5551(a * 256 + b)) + end + elseif fmt == 0 and siz == 3 then -- RGBA32 + return w, h, sub(d, addr + 1, addr + n * 4) + elseif fmt == 2 then -- CI4 / CI8 + local pal, np = {}, 0 + if tlut ~= nil and tlut.data ~= nil then + local base = tlut.data + (siz == 0 and palette * 16 * 2 or 0) + np = (siz == 0) and 16 or 256 + for i = 0, np - 1 do + local a, b = byte(d, base + i * 2 + 1, base + i * 2 + 2) + pal[i + 1] = char(rgba5551(a * 256 + b)) + end + end + if np == 0 then + np = 256 + for i = 1, np do pal[i] = "\255\0\255\255" end + end + for i = 0, n - 1 do + local idx = (siz == 0) and nibble(i) or byte(d, addr + i + 1) + out[i + 1] = pal[idx % np + 1] + end + elseif fmt == 3 then -- IA16 / IA8 / IA4 + for i = 0, n - 1 do + local l, a + if siz == 2 then + local x, y = byte(d, addr + i * 2 + 1, addr + i * 2 + 2) + l, a = x, y + elseif siz == 1 then + local v = byte(d, addr + i + 1) + l, a = floor(v / 16) * 17, v % 16 * 17 + else + local v = nibble(i) + l, a = floor(floor(v / 2) * 255 / 7), (v % 2 == 1) and 255 or 0 + end + out[i + 1] = char(l, l, l, a) + end + elseif fmt == 4 then -- I8 / I4 + for i = 0, n - 1 do + local l = (siz == 1) and byte(d, addr + i + 1) or nibble(i) * 17 + out[i + 1] = char(l, l, l, 255) + end + else + for i = 0, n - 1 do out[i + 1] = "\255\0\255\255" end + end + return w, h, concat(out) +end + +StadiumFragment.decodeTexture = decodeTexture + +-- ------- the whole model + +-- Constant tracks collapse to a scalar; most channels never move, and that +-- fold is most of the reason the packed set is 34 megabytes rather than a +-- great deal more. +local function compress(values, n, nd) + local first = roundTo(values[1], nd) + for i = 2, n do + if roundTo(values[i], nd) ~= first then + local out = {} + for k = 1, n do out[k] = roundTo(values[k], nd) end + return out + end + end + return first +end + +-- Every distinct effect callback in the geo layout, once, in the order it +-- first appears -- see fragment.py's dedupe_fx for why the order is written +-- down rather than left to a hash table. +local function dedupeFx(nodes) + local seen, out = {}, {} + for i = 1, #nodes do + local node = nodes[i] + local key = node.bone .. "," .. node.callback .. "," .. tostring(node.arg) + if not seen[key] then + seen[key] = true + out[#out + 1] = { bone = node.bone, callback = node.callback, + arg = node.arg } + end + end + return out +end + +-- One decompressed archive entry -> the table StadiumBuild packs. Mirrors +-- fragment.extract, including which fields exist and what they are named. +function StadiumFragment.extract(data, name) + local frag, err = StadiumFragment.open(data, name) + if not frag then return nil, err end + local m, mErr = newModel(frag) + if not m then return nil, mErr end + m:build() + + local auxAnims = {} + for i = 1, #m.auxAnims do auxAnims[i] = newAux(frag, m.auxAnims[i]) end + + local texIndexMap, texOut = {}, {} + + local function register(texIdx, tlut, pal) + local key = texIdx .. "," .. tlut .. "," .. pal + local hit = texIndexMap[key] + if hit ~= nil then return hit end + if texIdx < 0 or texIdx >= #m.textures then return -1 end + local slot = #texOut -- 0-based, as the file + texIndexMap[key] = slot + local tl = (tlut >= 0 and tlut < #m.tluts) and m.tluts[tlut + 1] or nil + local w, h, rgba = decodeTexture(frag, m.textures[texIdx + 1], tl, pal) + texOut[slot + 1] = { index = texIdx, w = w, h = h, rgba = rgba } + return slot + end + + -- an animated material can swap to any texture its channel names, so all of + -- them have to be decoded up front + for _, p in ipairs(m.prims) do + if p.ntris > 0 then + local pal = m:tilePalette(p.mat) + register(p.tex, p.tlut, pal) + if p.texAnim >= 0 then + for _, a in ipairs(auxAnims) do + local tr, tn = a:track(p.texAnim) + local vals, vn = unique(tr, tn) + for i = 1, vn do register(vals[i], p.tlut, pal) end + end + end + end + end + + local prims = {} + for _, p in ipairs(m.prims) do + if p.ntris > 0 then + local pal = m:tilePalette(p.mat) + local ti = texIndexMap[p.tex .. "," .. p.tlut .. "," .. pal] or -1 + -- texture-table index -> slot in texOut, for the animated swap + local texMap = nil + if p.texAnim >= 0 then + for _, a in ipairs(auxAnims) do + local tr, tn = a:track(p.texAnim) + local vals, vn = unique(tr, tn) + for i = 1, vn do + local slot = texIndexMap[vals[i] .. "," .. p.tlut .. "," .. pal] + if slot ~= nil then + texMap = texMap or {} + texMap[vals[i]] = slot + end + end + end + end + local tw, th = 32, 32 + if ti >= 0 then + tw, th = m.textures[p.tex + 1].w, m.textures[p.tex + 1].h + end + local pos, uv, nrm, skin, idx = {}, {}, {}, {}, {} + for i = 1, p.nverts do + local v = p.verts[i] + pos[i * 3 - 2], pos[i * 3 - 1], pos[i * 3] = v[1], v[2], v[3] + uv[i * 2 - 1] = (v[4] / 32.0) / tw + uv[i * 2] = (v[5] / 32.0) / th + nrm[i * 3 - 2] = v[6] / 127.0 + nrm[i * 3 - 1] = v[7] / 127.0 + nrm[i * 3] = v[8] / 127.0 + skin[i] = v[10] + end + local ni = 0 + for i = 1, p.ntris do + local tri = p.tris[i] + idx[ni + 1], idx[ni + 2], idx[ni + 3] = tri[1], tri[2], tri[3] + ni = ni + 3 + end + prims[#prims + 1] = { + tex = ti, cull = p.cull, texAnim = p.texAnim, texMap = texMap, + pos = pos, uv = uv, nrm = nrm, skin = skin, nverts = p.nverts, + idx = idx, nidx = ni, + } + end + end + + local anims = {} + for i = 1, #m.anims do + local a = newAnim(frag, m.anims[i]) + local nf = a.nFrames > 1 and a.nFrames or 1 + local tracks = {} + for bi = 1, #m.bones do + local b = m.bones[bi] + local ch = b.chan + if ch >= 0 and a:sampleTrs(ch, 0, b.t, b.r, b.s) ~= nil then + local ts, rs, ss = {}, {}, {} + for k = 1, 3 do ts[k], rs[k], ss[k] = {}, {}, {} end + for fr = 0, nf - 1 do + local t, r, s = a:sampleTrs(ch, fr, b.t, b.r, b.s) + for k = 1, 3 do + ts[k][fr + 1], rs[k][fr + 1], ss[k][fr + 1] = t[k], r[k], s[k] + end + end + tracks[bi] = { + t = { compress(ts[1], nf, 3), compress(ts[2], nf, 3), + compress(ts[3], nf, 3) }, + r = { compress(rs[1], nf, 0), compress(rs[2], nf, 0), + compress(rs[3], nf, 0) }, + s = { compress(ss[1], nf, 5), compress(ss[2], nf, 5), + compress(ss[3], nf, 5) }, + } + end + end + anims[i] = { index = i - 1, frames = nf, flags = a.flags, + channels = a.nChannels, loopStart = a.loopStart, + tracks = tracks } + end + + local auxOut = {} + for i = 1, #auxAnims do + local a = auxAnims[i] + local chans = {} + for c = 0, a.nChannels - 1 do + local tr, tn = a:track(c) + tr.n = tn + chans[c + 1] = tr + end + auxOut[i] = { index = i - 1, frames = a.nFrames > 1 and a.nFrames or 1, + flags = a.flags, loopStart = a.loopStart, channels = chans } + end + + return { + species = m.species, + file = name, + rootScale = m.rootScale, + bones = m.bones, + textures = texOut, + prims = prims, + anims = anims, + auxAnims = auxOut, + fx = dedupeFx(m.fx), + warnings = m.warnings, + } +end + +return StadiumFragment diff --git a/lib/StadiumFx.lua b/lib/StadiumFx.lua new file mode 100644 index 0000000..1f60de4 --- /dev/null +++ b/lib/StadiumFx.lua @@ -0,0 +1,436 @@ +-- STADIUM battles: the generated fire and gas stand-ins. +-- +-- A port of model_extract/pipeline/effects.py, plus the bind-pose measurement +-- build.py sizes them against. +-- +-- IMPORTANT: nothing here is extracted game data. The real tail flame, mane +-- fire and gas are drawn by procedural callbacks that live in a different +-- fragment -- geo command 0x08 records an attachment point and +-- func_80014A60 calls node->unk_10, and the model file supplies only two +-- empty display lists plus zeroed scratch buffers for it to fill. Those +-- callbacks have not been ported, so the models genuinely contain no flame +-- mesh and no flame texture: Charmander's texture set is eyes, claws, teeth +-- and skin. +-- +-- What follows is an ORIGINAL, procedurally generated replacement -- looping +-- flipbook noise on a pair of crossed quads, anchored to the exact bone the +-- callback hangs off so it sits where the real effect would and follows the +-- animation. Seeds derive from the species number, so a given Pokemon always +-- generates the same flame. +-- +-- Which species get one is the game's own grouping: every species sharing a +-- callback shares an effect. +-- +-- 0x810000D8 Charmander, Charmeleon, Charizard, Magmar, Moltres tail flame +-- 0x81000108 Ponyta, Rapidash, Moltres's wings small flame +-- 0x810000E0 Gastly (only) gas cloud + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local StadiumFx = {} + +local floor = math.floor +local sqrt = math.sqrt +local sin, cos = math.sin, math.cos +local char = string.char +local concat = table.concat +local pi = math.pi + +local FIRE_TAIL = 0x810000D8 +local FIRE_SMALL = 0x81000108 +local AURA = 0x810000E0 + +-- Desired size as a fraction of the model's world-space height: length, width. +StadiumFx.SIZES = { + fire_tail = { 0.40, 0.22 }, + fire_small = { 0.075, 0.042 }, + gas = { 1.05, 1.05 }, +} + +-- ------- 32-bit exclusive-or, in arithmetic +-- +-- The generator below is an xorshift, so it needs a real 32-bit xor and a +-- real 32-bit wrap. Written out rather than taken from LuaJIT's `bit`, which +-- works in SIGNED 32-bit and would need converting back on every step -- see +-- the same note in StadiumFragment. + +local function bxor32(a, b) + local r, p = 0, 1 + for _ = 1, 32 do + local x, y = a % 2, b % 2 + if x ~= y then r = r + p end + a, b, p = floor(a / 2), floor(b / 2), p * 2 + end + return r +end + +-- ------- deterministic noise + +local Rng = {} +Rng.__index = Rng + +local function newRng(seed) + local s = seed % 0x100000000 + if s == 0 then s = 0x9E3779B9 end + return setmetatable({ s = s }, Rng) +end + +function Rng:next() + local x = self.s + x = bxor32(x, (x % 0x80000) * 0x2000) -- x ^= (x << 13) + x = bxor32(x, floor(x / 0x20000)) -- x ^= x >> 17 + x = bxor32(x, (x % 0x8000000) * 0x20) -- x ^= (x << 5) + self.s = x % 0x100000000 + return self.s +end + +function Rng:unit() + return self:next() / 0x100000000 +end + +-- A w-by-h lattice of unit noise, consumed row by row so the sequence -- and +-- therefore the texture -- is reproducible. +local function lattice(rng, w, h) + local g = {} + for y = 1, h do + local row = {} + for x = 1, w do row[x] = rng:unit() end + g[y] = row + end + return g +end + +local function smooth(t) + return t * t * (3 - 2 * t) +end + +-- Bilinear value noise on a torus, so the field tiles in both axes. +local function sample(grid, x, y) + local h = #grid + local w = #grid[1] + local fx0, fy0 = floor(x), floor(y) + local x0, y0 = fx0 % w, fy0 % h + local x1, y1 = (x0 + 1) % w, (y0 + 1) % h + local fx, fy = smooth(x - fx0), smooth(y - fy0) + local r0, r1 = grid[y0 + 1], grid[y1 + 1] + local a = r0[x0 + 1] + (r0[x1 + 1] - r0[x0 + 1]) * fx + local b = r1[x0 + 1] + (r1[x1 + 1] - r1[x0 + 1]) * fx + return a + (b - a) * fy +end + +-- Sum octaves of tileable noise. +local function fbm(grids, x, y, scale) + local total, amp, norm = 0.0, 1.0, 0.0 + for i = 1, #grids do + local f = scale * 2 ^ (i - 1) + total = total + sample(grids[i], x * f, y * f) * amp + norm = norm + amp + amp = amp * 0.5 + end + return total / norm +end + +-- Intensity -> RGBA, through a piecewise ramp. +local function ramp(stops, t) + if t < 0.0 then t = 0.0 elseif t > 1.0 then t = 1.0 end + for i = 1, #stops - 1 do + local a, b = stops[i], stops[i + 1] + if t <= b[1] then + local k = 0.0 + if b[1] ~= a[1] then k = (t - a[1]) / (b[1] - a[1]) end + return floor(a[2] + (b[2] - a[2]) * k), floor(a[3] + (b[3] - a[3]) * k), + floor(a[4] + (b[4] - a[4]) * k), floor(a[5] + (b[5] - a[5]) * k) + end + end + local last = stops[#stops] + return last[2], last[3], last[4], last[5] +end + +local FIRE_RAMP = { + { 0.00, 0, 0, 0, 0 }, + { 0.30, 120, 24, 8, 90 }, + { 0.52, 226, 78, 16, 205 }, + { 0.74, 252, 176, 44, 245 }, + { 1.00, 255, 246, 214, 255 }, +} + +local GAS_RAMP = { + { 0.00, 0, 0, 0, 0 }, + { 0.34, 52, 26, 78, 70 }, + { 0.60, 96, 52, 140, 140 }, + { 0.82, 148, 96, 196, 190 }, + { 1.00, 208, 176, 236, 215 }, +} + +local TRANSPARENT = char(0, 0, 0, 0) + +-- An upward-advected noise plume. Scrolling by an exact multiple of the +-- lattice over the frame count is what makes the loop seamless. +local function fireFrames(seed, w, h, frames, wisp) + wisp = wisp or 1.0 + local rng = newRng(seed) + local grids = { lattice(rng, 8, 8), lattice(rng, 16, 16), + lattice(rng, 32, 32) } + local out = {} + for f = 0, frames - 1 do + local t = f / frames + local buf = {} + for i = 1, w * h do buf[i] = TRANSPARENT end + for y = 0, h - 1 do + local v = y / (h - 1) -- 0 at the base, 1 at the tip + -- plume envelope: wide and hot at the base, pinched at the tip + local taper = 1.0 - v + if taper < 0.0 then taper = 0.0 end + taper = taper ^ 0.42 + for x = 0, w - 1 do + local u = (x / (w - 1)) * 2 - 1 -- -1 .. 1 across the flame + local denom = taper * 0.95 + if denom < 0.10 then denom = 0.10 end + local radial = 1.0 - (u < 0 and -u or u) / denom + if radial > 0 then + radial = radial ^ 0.7 + local n = fbm(grids, x / w, (y / h) - t, 3.0) + local lick = 0.55 + 0.75 * (n - 0.5) * wisp + local inten = radial * (0.55 + 0.8 * taper) * lick + inten = inten - 0.16 * v -- cool towards the tip + if inten > 0.02 then + local r, g, b, a = ramp(FIRE_RAMP, inten) + -- +Y in texture space is up + buf[(h - 1 - y) * w + x + 1] = char(r, g, b, a) + end + end + end + end + out[f + 1] = concat(buf) + end + return w, h, out +end + +-- Slow swirling haze that fades out towards the rim. +local function gasFrames(seed, w, h, frames) + local rng = newRng(seed) + local grids = { lattice(rng, 8, 8), lattice(rng, 16, 16), + lattice(rng, 32, 32) } + local out = {} + for f = 0, frames - 1 do + local t = f / frames + local buf = {} + for i = 1, w * h do buf[i] = TRANSPARENT end + local ang = t * 2 * pi + local ca, sa = cos(ang), sin(ang) + for y = 0, h - 1 do + for x = 0, w - 1 do + local dx = (x / (w - 1)) * 2 - 1 + local dy = (y / (h - 1)) * 2 - 1 + local d = sqrt(dx * dx + dy * dy) + if d < 1.0 then + local falloff = (1.0 - d) ^ 0.85 + -- rotate the sample point so the haze churns without popping + local sx = dx * ca - dy * sa + local sy = dx * sa + dy * ca + local n = fbm(grids, sx * 0.5 + 0.5, sy * 0.5 + 0.5 - t, 2.5) + local inten = falloff * (0.78 + 1.30 * (n - 0.44)) + if inten > 0.03 then + local r, g, b, a = ramp(GAS_RAMP, inten) + buf[y * w + x + 1] = char(r, g, b, a) + end + end + end + end + out[f + 1] = concat(buf) + end + return w, h, out +end + +-- Two quads at right angles, so the effect reads from any angle. `axis` picks +-- which bone-local direction the quad grows along: bone-local +X runs down the +-- limb, so a flame laid out along X comes out lying sideways, and 'y' is that +-- same quad turned a quarter left about Z, which stands it up. `centred` +-- straddles the origin instead of growing from it. +local function crossedQuads(bone, length, width, axis, centred) + local pos, uv, nrm, skin, idx = {}, {}, {}, {}, {} + local ST = { { 0, 0 }, { 1, 0 }, { 1, 1 }, { 0, 1 } } + local nv, ni = 0, 0 + for q = 0, 1 do + local base = nv + for k = 1, 4 do + local s, t = ST[k][1], ST[k][2] + local a = (s - 0.5) * width + local b = centred and (t - 0.5) * length or t * length + local px, py, pz + if axis == "x" then + if q == 0 then px, py, pz = b, a, 0.0 else px, py, pz = b, 0.0, a end + else -- (x, y) -> (-y, x) + if q == 0 then px, py, pz = -a, b, 0.0 else px, py, pz = 0.0, b, a end + end + pos[nv * 3 + 1], pos[nv * 3 + 2], pos[nv * 3 + 3] = px, py, pz + uv[nv * 2 + 1], uv[nv * 2 + 2] = s, 1.0 - t + if q == 0 then + nrm[nv * 3 + 1], nrm[nv * 3 + 2], nrm[nv * 3 + 3] = 0.0, 0.0, 1.0 + else + nrm[nv * 3 + 1], nrm[nv * 3 + 2], nrm[nv * 3 + 3] = 1.0, 0.0, 0.0 + end + skin[nv + 1] = bone + nv = nv + 1 + end + idx[ni + 1], idx[ni + 2], idx[ni + 3] = base, base + 1, base + 2 + idx[ni + 4], idx[ni + 5], idx[ni + 6] = base, base + 2, base + 3 + ni = ni + 6 + end + return { pos = pos, uv = uv, nrm = nrm, skin = skin, nverts = nv, + idx = idx, nidx = ni } +end + +-- ------- the bind pose these are sized against +-- +-- build.py's bind_extent, kept in its own 4x4 column-major convention rather +-- than folded into StadiumBuild's 3x4 walk. The two agree -- they are the +-- same skeleton -- but the effect sizes come out of THIS one's per-bone scale +-- measurement, and rewriting it into the other convention is exactly the kind +-- of change that moves a byte without anyone noticing. + +local function trs(t, r, s) + local function S(v) return sin(v / 32768 * pi) end + local function C(v) return cos(v / 32768 * pi) end + local sx, cx = S(r[1]), C(r[1]) + local sy, cy = S(r[2]), C(r[2]) + local sz, cz = S(r[3]), C(r[3]) + return { cy * cz * s[1], cy * sz * s[1], -sy * s[1], 0, + (sx * sy * cz - cx * sz) * s[2], (sx * sy * sz + cx * cz) * s[2], + sx * cy * s[2], 0, + (cx * sy * cz + sx * sz) * s[3], (cx * sy * sz - sx * cz) * s[3], + cx * cy * s[3], 0, + t[1], t[2], t[3], 1 } +end + +local function mul(a, b) + local r = {} + for c = 0, 3 do + for i = 1, 4 do + r[c * 4 + i] = a[i] * b[c * 4 + 1] + a[4 + i] * b[c * 4 + 2] + + a[8 + i] * b[c * 4 + 3] + a[12 + i] * b[c * 4 + 4] + end + end + return r +end + +-- (height of the bind pose, per-bone local scale). Height rather than the +-- largest dimension: sizing off the max would scale Moltres's flames to its +-- wingspan. +function StadiumFx.bindExtent(data) + local root = trs({ 0, 0, 0 }, { 0, 0, 0 }, data.rootScale) + local acc, uns, mats = {}, {}, {} + for i = 1, #data.bones do + local b = data.bones[i] + local p = b.parent + local pa = (p >= 0) and acc[p + 1] or { 1.0, 1.0, 1.0 } + local pu = (p >= 0) and uns[p + 1] or root + local u = mul(pu, trs({ b.t[1] * pa[1], b.t[2] * pa[2], b.t[3] * pa[3] }, + b.r, { 1, 1, 1 })) + local a = { pa[1] * b.s[1], pa[2] * b.s[2], pa[3] * b.s[3] } + local m = {} + for k = 1, 16 do m[k] = u[k] end + for k = 1, 4 do + m[k] = m[k] * a[1] + m[4 + k] = m[4 + k] * a[2] + m[8 + k] = m[8 + k] * a[3] + end + acc[i], uns[i], mats[i] = a, u, m + end + + local lo = { 1e9, 1e9, 1e9 } + local hi = { -1e9, -1e9, -1e9 } + for _, prim in ipairs(data.prims) do + local pos, skin = prim.pos, prim.skin + for i = 1, prim.nverts do + local m = mats[skin[i] + 1] + if m then + local x, y, z = pos[i * 3 - 2], pos[i * 3 - 1], pos[i * 3] + local wx = m[1] * x + m[5] * y + m[9] * z + m[13] + local wy = m[2] * x + m[6] * y + m[10] * z + m[14] + local wz = m[3] * x + m[7] * y + m[11] * z + m[15] + if wx < lo[1] then lo[1] = wx end + if wy < lo[2] then lo[2] = wy end + if wz < lo[3] then lo[3] = wz end + if wx > hi[1] then hi[1] = wx end + if wy > hi[2] then hi[2] = wy end + if wz > hi[3] then hi[3] = wz end + end + end + end + local extent = (lo[1] <= hi[1]) and (hi[2] - lo[2]) or 1.0 + + -- how much each bone scales its own local space, so an effect can divide it + -- back out and come out the size it asked for wherever it hangs + local scales = {} + for i = 1, #mats do + local m = mats[i] + scales[i] = sqrt(m[1] * m[1] + m[2] * m[2] + m[3] * m[3]) + end + return extent, scales +end + +-- ------- what a species gets + +-- Returns a list of { kind, bone, geo, w, h, frames }, or an empty list. +function StadiumFx.buildFor(species, fx, extent, boneScale) + local out = {} + for _, node in ipairs(fx) do + local cb, bone = node.callback, node.bone + if bone >= 0 and bone < #boneScale then + local k = boneScale[bone + 1] + if k == 0 then k = 1.0 end + if cb == FIRE_TAIL then + local fl, fw = StadiumFx.SIZES.fire_tail[1], StadiumFx.SIZES.fire_tail[2] + local w, h, fr = fireFrames(species * 7919 + 1, 32, 64, 8) + out[#out + 1] = { kind = "fire", bone = bone, w = w, h = h, frames = fr, + geo = crossedQuads(bone, extent * fl / k, + extent * fw / k, "y", false) } + elseif cb == FIRE_SMALL then + local fl, fw = StadiumFx.SIZES.fire_small[1], + StadiumFx.SIZES.fire_small[2] + local w, h, fr = fireFrames(species * 6271 + bone, 24, 40, 8, 1.25) + out[#out + 1] = { kind = "fire", bone = bone, w = w, h = h, frames = fr, + geo = crossedQuads(bone, extent * fl / k, + extent * fw / k, "y", false) } + elseif cb == AURA and species == 92 then -- Gastly only + local fl, fw = StadiumFx.SIZES.gas[1], StadiumFx.SIZES.gas[2] + local w, h, fr = gasFrames(species * 5237 + 3, 48, 48, 10) + out[#out + 1] = { kind = "gas", bone = bone, w = w, h = h, frames = fr, + geo = crossedQuads(bone, extent * fl / k, + extent * fw / k, "y", true) } + end + end + end + return out +end + +-- Append the generated prims and their flipbook textures to a model, exactly +-- as build.py's attach_effects does. Returns how many were made. +function StadiumFx.attach(data, species) + if not (data.fx and #data.fx > 0) then return 0 end + local extent, boneScale = StadiumFx.bindExtent(data) + local made = StadiumFx.buildFor(species, data.fx, extent, boneScale) + for _, e in ipairs(made) do + local first = #data.textures -- 0-based, as the file + for i = 1, #e.frames do + data.textures[first + i] = { index = -1, w = e.w, h = e.h, + generated = true, rgba = e.frames[i] } + end + local g = e.geo + local fxFrames = {} + for i = 1, #e.frames do fxFrames[i] = first + i - 1 end + data.prims[#data.prims + 1] = { + tex = first, cull = 0, texAnim = -1, texMap = nil, + generated = true, effect = e.kind, + blend = (e.kind == "fire") and "add" or "alpha", + fxFrames = fxFrames, + pos = g.pos, uv = g.uv, nrm = g.nrm, skin = g.skin, nverts = g.nverts, + idx = g.idx, nidx = g.nidx, + } + end + return #made +end + +return StadiumFx diff --git a/lib/StadiumInstall.lua b/lib/StadiumInstall.lua new file mode 100644 index 0000000..a72e6d1 --- /dev/null +++ b/lib/StadiumInstall.lua @@ -0,0 +1,351 @@ +-- 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" + +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 = text:match("^(%S+)%s+(%d+)%s*(%S*)") + if not format then return nil end + return { format = format, count = tonumber(count), md5 = md5 } +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 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 STADIUM rungs can be offered at all: either the packs have been +-- built from the player's ROM, or the mod folder already carries a set. +function StadiumInstall.available() + if StadiumInstall.ready() then return true end + return shipped() +end + +-- Whether there is work to do: something to build from, and nothing usable +-- yet. +-- +-- A checkout that already carries a set is NOT pending. Building anyway would +-- be correct and would also mean a ten-second loading screen on the first run +-- of every checkout, to arrive at the files that were already sitting there. +function StadiumInstall.pending() + if StadiumInstall.available() then return false end + return StadiumInstall.romPresent() +end + +function StadiumInstall.forget() + readyCache = nil +end + +-- ------- building + +local job = nil +local status = { state = "idle", done = 0, total = StadiumInstall.COUNT } + +StadiumInstall.status = status + +local function writePack(species, bytes) + 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 + 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\n"):format(StadiumInstall.FORMAT, job.total, + tostring(job.md5 or ""))) + 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 diff --git a/lib/StadiumMon.lua b/lib/StadiumMon.lua new file mode 100644 index 0000000..25030e9 --- /dev/null +++ b/lib/StadiumMon.lua @@ -0,0 +1,489 @@ +-- STADIUM battles: one Pokemon, standing on its tile. +-- +-- The side's live state -- which species is out, the rig posing it, which +-- animation the fight has asked for and how far through it is, and the +-- matrix that puts it on its cell at the right size facing the right way. +-- Stadium owns the pair of these; StadiumRig owns the arithmetic. +-- +-- ------- how big a Pokemon is +-- +-- The one genuinely invented number in this mode, and it is worth saying +-- why it is invented rather than measured. +-- +-- The flat 2D-3D mode has an exact answer: a full-size 56-pixel pic covers +-- one 16-pixel overworld square, so a canvas pixel is a fixed number of +-- world pixels and every species comes out at whatever its own artwork's +-- size implies (see BattleBillboard.FULL_W). The camera is then SOLVED to +-- make one square that big on screen (BattleCam). +-- +-- The Stadium models have no such anchor. Their units are the N64's, they +-- run from Caterpie at 9 units to Gyarados at 147 -- a sixteenfold spread, +-- where the Gen 1 pics span barely one and a half -- and the game they come +-- from framed each one with its own camera, which a fight staged on the +-- overworld cannot do because the two mons share a shot. +-- +-- Taken literally, that spread puts Caterpie at a couple of pixels on a +-- 144-pixel screen while Gyarados leaves the frame. So the range is +-- COMPRESSED rather than either honoured or discarded: a species is drawn +-- at REF_HEIGHT world pixels scaled by its own height over the set's +-- median, raised to SQUASH. At 1 that would be the raw sixteenfold spread; +-- at 0 every Pokemon would be the same size; at 0.55 the order and the +-- feel of the differences survive -- Onix and Gyarados tower, Diglett and +-- Caterpie are small enough to have to look for -- inside a range a shared +-- frame can hold. +-- +-- ------- and where its feet are +-- +-- The pack measures each model's lowest point against its own origin +-- (tools/stadium_pack.py's `stance`), and the answer splits the set in +-- three. 119 species sit within 5% of zero: the origin IS the floor, and +-- the game stood them on its field with it. A handful sit ABOVE it -- +-- Zubat, Magnemite, Geodude -- which is a hover the model is authored with. +-- The rest hang BELOW it -- Tentacruel, Gastly, Haunter, Weezing, Zapdos -- +-- which is a model centred on its origin rather than standing on it. +-- +-- So a model is stood on its own lowest point, and then given back as much +-- of its authored hover as the shot can hold -- HOVER_CAP of its own height, +-- no more. The middle group is unaffected either way, which is the check +-- that the rule is reading the data rather than correcting it. +-- +-- The cap is not tidiness. Stadium framed one Pokemon per camera and could +-- afford to hang Zubat three body-heights off the floor; this shot has the +-- foe's feet on GB row 56 of 144, so the same hover puts Zubat off the top +-- of the frame entirely -- which is exactly what it did before the cap. The +-- flat 2D-3D mode has the same constraint and answers it by bottom-aligning +-- every pic, hovering species included; this keeps the hover but spends +-- only the room there is. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local Mat4 = V.require("Mat4") +local StadiumPack = V.require("StadiumPack") +local StadiumRig = V.require("StadiumRig") + +local StadiumMon = {} +StadiumMon.__index = StadiumMon + +-- How tall a median Pokemon stands, in world pixels. +-- +-- Not picked by eye: it is what the FLAT mode already puts on those cells. +-- A full-size Gen 1 pic is 56 pixels for the foe and 64 for the player's +-- own, drawn with its feet on GB rows 56 and 96 of a 144-row frame -- so a +-- full-size mon covers 39% of the frame at the far cell and 44% at the near +-- one. Against the lens BattleCam solves (about 38 world pixels of frame at +-- the far cell, 30 at the near one, because the near one is closer) both of +-- those work out at roughly fourteen world pixels. +-- +-- So this is the number that makes a median Stadium model exactly as big as +-- the artwork it replaces, which is what keeps the composition the camera +-- was solved for. +StadiumMon.REF_HEIGHT = 14 + +-- The set's own median bind height, in game units (tools/stadium_pack.py +-- --report prints it). Only ever a reference point for the ratio above, so +-- a re-extraction that moved it slightly changes nothing but the middle of +-- the ladder. +StadiumMon.MEDIAN = 52.25 + +-- How much of the raw size spread survives. See the header. +StadiumMon.SQUASH = 0.5 + +-- And hard stops either end, because a compression is not a guarantee. The +-- ceiling is what keeps Onix and Gyarados inside a frame whose top edge is +-- only 56 GB rows above the foe's own feet: past about this they stop being +-- imposing and start being cropped. +StadiumMon.MIN_HEIGHT = 5 +StadiumMon.MAX_HEIGHT = 18 + +-- How much of an authored hover survives, as a fraction of the Pokemon's +-- own height. See the header: Stadium could hang a flier three body-heights +-- up because it framed one Pokemon at a time. +StadiumMon.HOVER_CAP = 0.5 + +-- The animation clock. Every animation in the set is authored at 30 fps +-- (model_extract/README.md), and the eyes run on their own counter at the +-- same rate. +StadiumMon.FPS = StadiumPack.FPS + +-- ------- coming out of the ball +-- +-- The engine grows its flat pic in the Game Boy's own three steps -- 0, then +-- 3/7, then 5/7, then full -- across the twelve frames after the ball opens +-- (BattleState.growInScale). Two things about that do not carry to a model. +-- +-- It is three steps, which on a 56-pixel sprite is a chunky pop and on a +-- smooth 3D model is just a pop. And it starts AFTER the ball: measured, the +-- poof animation runs for 27 frames and `startGrowIn` fires on the frame +-- after it ends, so the Pokemon does not begin to exist until the ball has +-- finished opening -- which reads as the ball opening and then a Pokemon +-- being switched on beside it. +-- +-- So the model runs its own ramp, started when the POOF begins rather than +-- when it ends, and continuous rather than stepped: it grows out of nothing +-- while the ball is opening and reaches full size as the engine's own grow +-- finishes. GROW_TIME is measured off that -- 27 frames of poof plus the +-- engine's 12 of grow is 39, which is this. +StadiumMon.GROW_TIME = 0.65 + +-- How far an animation may carry the Pokemon off its tile, in the Pokemon's +-- own body-heights, before the excess is taken back out (StadiumRig.anchor). +-- +-- Measured against the frame rather than chosen by eye. A mon is drawn +-- REF_HEIGHT world pixels tall and the GB frame holds about 38 world pixels +-- at the far cell, with the foe's feet on row 56 of 144 -- so there is +-- roughly one body-height of room above it and about one and a half either +-- side. Three quarters of a height keeps every part of a travelling Pokemon +-- inside that with a margin, and leaves the 83 species that never reach it +-- untouched. +StadiumMon.TRAVEL = 0.75 + +-- ------- the animation the fight is asking for +-- +-- Each entry says which context slot to look up, whether it loops, and +-- what it falls back to when the species has no animation in that slot. +-- ------- there is no hit reaction, and there never was +-- +-- This used to carry `hit` and `flinch` states, played when damage landed, +-- resolving through context slots 166 and 178. Both were wrong, and the data +-- says so plainly once the move table is read alongside them: +-- +-- Bulbasaur's slot 166 is a 95-frame animation that 66 of its moves play. +-- Pidgey's is 138 frames -- four and a half seconds -- and 111 of its moves +-- play it. Slot 178, and 173, 179, 180 and 181, all point at the same one. +-- +-- A four-and-a-half-second animation that most of the move table uses is the +-- species' DEFAULT ATTACK, not a flinch, which is why being hit looked like +-- swinging: it literally was the swing. +-- +-- Nor is the reaction hiding elsewhere. Exactly one animation per species is +-- claimed by no slot and no move, and it is the same length as the idle for +-- essentially every one of them -- 48/48, 56/56, 60/60, 84/84 -- so it is a +-- second standby loop, not a recoil. The set has no damage reaction in it. +-- +-- So damage plays nothing, and the Pokemon carries on with what it was doing. +-- That is not a gap: the engine flashes the screen, blinks the pic and drains +-- the bar, which is how Gen 1 says "that hurt" and is already in the frame. +local STATES = { + idle = { slot = "idle", loop = true }, + entrance = { slot = "entrance", loop = false, next = "idle" }, + faint = { slot = "faint", loop = false, hold = true }, + -- A move names its own animation out of the move table. `attack_default` + -- is the fallback for one the table has nothing for -- which is what slot + -- 166 actually is, so the generic swing is now a real swing rather than + -- the standby loop it used to resolve to. + attack = { slot = "attack_default", loop = false, next = "idle" }, +} + +function StadiumMon.new(side) + return setmetatable({ + side = side, -- "player" or "enemy" + species = nil, -- the dex number currently modelled + model = nil, + rig = nil, + state = "idle", + anim = nil, -- index into model.anims + time = 0, -- seconds into it + loop = true, + hold = false, + aux = nil, -- the texture animation running alongside + visible = false, + scale = 1, -- the send-out grow, 1 the rest of the time + }, StadiumMon) +end + +function StadiumMon:release() + if self.rig then self.rig:release() end + self.rig, self.model, self.species = nil, nil, nil +end + +-- ------- which species this side is showing +-- +-- Returns true when the model is ready to draw. A species with no pack, one +-- whose meshes would not build, or one whose animation data is corrupt at +-- source answers false -- and Stadium then leaves that side to the flat +-- card, which is a per-POKEMON decline rather than a per-battle one: a fight +-- can perfectly well have a model on one side and a pic on the other. +-- +-- ------- the three the extraction cannot read +-- +-- Exeggutor, Tangela and Magmar come out of the ROM with standby loops that +-- throw bones hundreds of units off the body -- the game's own index +-- arithmetic evidently reads those channel streams differently from the way +-- this does. StadiumBuild.idleIsBroken measures it and the pack carries the +-- verdict as `staticPose`. +-- +-- That flag used to mean "stand this one still in its bind pose", on the +-- reasoning that a Pokemon standing there looking like itself beats one +-- coming apart. It does -- but only just. A bind pose is a RIGGING pose, not +-- a portrait: arms out, neck straight, nothing where the artwork put it. Set +-- among a hundred and forty-eight species that breathe, the three that hold +-- a T-pose for the whole fight do not read as "these ones do not animate", +-- they read as broken -- which they are. +-- +-- So they decline instead, and the Game Boy's own battle sprite stands on +-- the tile in front of the same arena, lit the same way, drawn by the same +-- 2D-3D path every species uses when its model is unavailable. The mode +-- already had that fallback for a species with no pack at all; this is three +-- more species taking it. +-- +-- Keyed on the DATA rather than on a list of dex numbers on purpose: the +-- test that produced the flag is in the packer, so a re-extraction that +-- fixes those streams -- or breaks a fourth species -- moves this with it +-- and nothing here has to be edited. +function StadiumMon:setSpecies(dex) + if dex == self.species then return self.rig ~= nil end + if self.rig then self.rig:release() end + self.rig, self.model, self.species = nil, nil, dex + self.grow, self.grewOwn = nil, nil + if not dex then return false end + local model = StadiumPack.load(dex) + if not model then return false end + if model.staticPose then return false end + local rig = StadiumRig.new(model) + if not rig then return false end + self.model, self.rig = model, rig + -- a new Pokemon on the field opens on its standby loop; whoever sent it + -- out asks for the entrance a moment later + self.state, self.anim, self.time = nil, nil, 0 + self:play("idle") + return true +end + +-- ------- the state machine + +-- Which animation a context slot resolves to for this species, or nil. +function StadiumMon:slotAnim(name) + local model = self.model + local slot = model and StadiumPack.SLOT[name] + if not slot then return nil end + local index = model.ctx[slot] + if not index or index == StadiumPack.NONE then return nil end + return index + 1 +end + +-- Start a state. `animIndex` overrides the state's own slot lookup, which +-- is what an attack uses. +function StadiumMon:play(state, animIndex, auxIndex) + local model = self.model + if not model then return false end + local def = STATES[state] or STATES.idle + local index = animIndex + if not index and def.slot then index = self:slotAnim(def.slot) end + if not index and def.fallback then index = self:slotAnim(def.fallback) end + if not index then + -- the species has nothing for this; the standby loop is always there + if state == "idle" then index = 1 else return self:play("idle") end + end + local anim = model.anims[index] + if not anim then return false end + + self.state, self.anim, self.time = state, index, 0 + self.done = false + -- (a species whose animations are corrupt at source never gets this far: + -- setSpecies declines it outright and its flat pic stands instead) + self.loop = def.loop and true or false + self.hold = def.hold and true or false + -- The eyes that go with it. Every skeletal animation carries the texture + -- animation the battle table most often set alongside it (the pack's own + -- `aux`), and a move may name a different one -- a hit that leaves the + -- Pokemon confused swaps the open eye for the dizzy swirl. + self.aux = auxIndex or anim.aux + return true +end + +-- Ask for a state, but never interrupt one that outranks it. A faint is +-- final, and an entrance cannot be cut short by the standby loop it hands +-- on to. +local RANK = { idle = 0, entrance = 1, attack = 2, faint = 3 } + +function StadiumMon:request(state, animIndex, auxIndex) + if not self.model then return false end + local now = RANK[self.state] or 0 + local want = RANK[state] or 0 + if self.state == "faint" then return false end + -- an equal-ranked request RESTARTS: the second move of a two-hit turn + -- should swing again rather than be swallowed by the first + if want < now then return false end + return self:play(state, animIndex, auxIndex) +end + +-- The animation a move plays for this species, from the battle system's own +-- per-species table (model_extract's moves.json, packed into the .dsm). +-- `moveIndex` is the Gen 1 move id, which the engine's move defs carry as +-- `index` -- the same numbering, so no name mapping is needed. +function StadiumMon:attack(moveIndex) + local model = self.model + if not (model and moveIndex and moveIndex >= 1 + and moveIndex <= StadiumPack.N_MOVES) then + return false + end + local index = model.moveAnim[moveIndex] + if not index or index == StadiumPack.NONE then return false end + local aux = model.moveAux[moveIndex] + return self:request("attack", index + 1, + (aux and aux >= 0) and (aux + 1) or nil) +end + +-- ------- per frame + +function StadiumMon:update(dt) + -- kept for build(), which runs later in the same frame and needs it to + -- advance the anchor's filter (StadiumRig.anchor). Stashed before the + -- early-outs below, so a species with nothing to play still has one. + self.dt = dt or 0 + -- the ball-to-full-size ramp, which runs whether or not there is an + -- animation to play alongside it + if self.grow then + self.grow = self.grow + (dt or 0) / StadiumMon.GROW_TIME + if self.grow >= 1 then self.grow = nil end + end + local model = self.model + if not (model and self.anim) then return end + local anim = model.anims[self.anim] + if not anim then return end + self.time = self.time + (dt or 0) + if self.time >= anim.seconds and not self.loop then + if self.hold then + -- a faint stays down: hold the last frame rather than snapping back + -- to a standing pose the moment the animation runs out + self.time = math.max(0, anim.seconds - 1 / StadiumMon.FPS) + -- and SAY so, once. The clamp above means the clock can no longer be + -- asked whether the animation is over -- it stops a frame short of the + -- end and stays there forever -- and something has to know, because a + -- collapse that has finished is the moment the Pokemon may leave the + -- field (see Stadium's onField). + self.done = true + else + local nextState = (STATES[self.state] or {}).next or "idle" + self:play(nextState) + end + end +end + +-- ------- the grow +-- +-- Begin coming out of the ball. Answers whether it actually started, so the +-- caller can play the entrance alongside it and the engine's own send-out +-- seam a moment later does not restart what is already running. +function StadiumMon:beginGrow() + if self.grow or not self.model then return false end + self.grow = 0 + -- and remember that THIS arrival was ours to size, so the engine's own + -- three-step ramp is not consulted again for it. Ours starts earlier and + -- finishes a few frames sooner, and in that gap the engine's ramp still + -- reads 5/7 -- so falling back to it shrank the Pokemon from 0.96 back to + -- 0.71 and then snapped it to full, a visible hitch at the end of an + -- animation that exists to not have one. + self.grewOwn = true + return true +end + +-- How big this Pokemon is drawn this frame, as a fraction of its real size. +-- +-- Smoothstep rather than a straight ramp or an ease-out: the ball is opening +-- for the first half of this, so a curve that is already near full size by +-- then would have the Pokemon standing there while the ball is still coming +-- apart. Slow, then quick through the middle, then settling exactly as the +-- engine's own grow ends. +function StadiumMon:growScale() + local t = self.grow + if not t then return 1 end + if t <= 0 then return 0 end + if t >= 1 then return 1 end + return t * t * (3 - 2 * t) +end + +-- Whether a HELD animation -- which in practice means a faint -- has played +-- all the way through and is now sitting on its last frame. Always false for +-- a looping one, which never finishes, and for one that hands on to another +-- state, which has already stopped being itself by the time anyone can ask. +function StadiumMon:finished() + return self.done and true or false +end + +-- How tall this species stands on the map, in world pixels. +function StadiumMon:worldHeight() + local model = self.model + local h = model and model.height or 0 + if not (h > 0) then return StadiumMon.REF_HEIGHT end + local k = (h / StadiumMon.MEDIAN) ^ StadiumMon.SQUASH + local out = StadiumMon.REF_HEIGHT * k + if out < StadiumMon.MIN_HEIGHT then out = StadiumMon.MIN_HEIGHT end + if out > StadiumMon.MAX_HEIGHT then out = StadiumMon.MAX_HEIGHT end + return out +end + +-- How wide this Pokemon stands, in world pixels -- the same scale +-- worldHeight is in, so a caller can size something to its footprint. +-- +-- Only STADIUM B asks: it needs to know how big a platform to put under a +-- mon, and "as tall as it is" is the wrong answer for a Snorlax, which is +-- half as tall as an Onix and three times as wide. +-- +-- The send-out grow is deliberately NOT folded in. A Pokemon scaling up out +-- of its ball should arrive on a platform that was already there, not one +-- that inflates under its feet. +function StadiumMon:worldRadius() + local model = self.model + if not model then return 0 end + local h = model.height or 0 + if not (h > 0) then return 0 end + return (model.radius or 0) * self:worldHeight() / h +end + +-- The model matrix: stand this Pokemon on world (x, groundY, z) facing +-- (faceX, faceZ), at whatever the send-out grow has done to its size. +-- +-- The vertices the rig writes are in the model's RAW units -- before the +-- model_root scale the game applies -- so the scale here carries that too, +-- and the floor offset is measured in the same raw units on the way in. +function StadiumMon:matrix(x, groundY, z, faceX, faceZ) + local model = self.model + if not model then return nil end + local root = model.rootScale + if not (root and root > 0) then root = 1 end + local k = root * self:worldHeight() / math.max(model.height, 1e-6) + k = k * (self.scale or 1) + -- stand it on its own lowest point, then give back as much of the + -- authored hover as the shot can hold (see the header) + local floor = model.floor or 0 + local hover = math.min(math.max(floor, 0), + StadiumMon.HOVER_CAP * math.max(model.height, 0)) + local lift = (floor - hover) / root + local yaw = 0 + if faceX and faceZ and (faceX ~= 0 or faceZ ~= 0) then + -- the card and the model share this convention: an unrotated model + -- faces +Z, which is map SOUTH, which is what "facing down" is in the + -- flat game (see Voxel3D's axis note) + yaw = math.atan2(faceX, faceZ) + end + self.yaw = yaw + return Mat4.mul( + Mat4.mul(Mat4.mul(Mat4.translate(x, groundY, z), Mat4.rotateY(yaw)), + Mat4.scale(k, k, k)), + Mat4.translate(0, -lift, 0)) +end + +-- Pose and skin for this frame. Separate from the draw because both the +-- SUN and the camera -- and, in a headset, both eyes -- want the same +-- skinned mesh, and skinning it once is the whole reason this is worth +-- doing on the CPU. +function StadiumMon:build() + if not (self.rig and self.model) then return false end + -- self.anim is nil while a species has nothing to play, and pose() reads + -- that as "the bind pose", which is exactly what is wanted + self.rig:pose(self.anim, self.time * StadiumMon.FPS, self.loop) + -- and then back onto the tile, because these animations were authored for + -- a camera that followed the Pokemon and this one does not move (see + -- StadiumRig.anchor) + self.rig:anchor(StadiumMon.TRAVEL, self.dt) + self.rig:skin(self.yaw or 0) + -- no clock of its own: the texture animation rides the frame pose() just + -- resolved, which is what keeps a blink inside its standby loop and a + -- fainted Pokemon's eyes shut once it has stopped moving + self.rig:textures(self.aux) + return true +end + +return StadiumMon diff --git a/lib/StadiumPack.lua b/lib/StadiumPack.lua new file mode 100644 index 0000000..f8ce3b9 --- /dev/null +++ b/lib/StadiumPack.lua @@ -0,0 +1,585 @@ +-- STADIUM battles: reading one species' model off disk. +-- +-- `NNN.dsm` holds one Pokemon Stadium battle model. It is written by +-- StadiumBuild, out of the player's own copy of that ROM, the first time the +-- mod runs (see StadiumInstall) -- and by tools/stadium_pack.py, which is the +-- oracle that Lua path is tested against. This file is the other half of that +-- format and nothing else: bytes in, tables out. What the tables MEAN is +-- StadiumRig's business (posing a skeleton) and StadiumMon's (which animation +-- a fight is asking for). +-- +-- Three things shape it. +-- +-- BINARY, NOT LUA. A species is a couple of hundred kilobytes of numbers, +-- most of it animation, and a Lua source file of that is a parse the loader +-- would pay for on every boot whether a battle happened or not. A byte +-- string is read once, on the frame a fight starts, and only for the two +-- species actually fighting. +-- +-- LAZY ANIMATIONS. Geometry, bones and textures are decoded on load -- +-- they are small, and every one of them is needed the moment the mon +-- appears. The animations are not: a fight uses idle, an entrance and +-- whichever handful of attacks come up, out of the seven to twenty-one a +-- species carries. So the load pass SCANS the animation block, recording +-- where each one starts and skipping the rest, and a track is decoded the +-- first time something plays it. That turns a 200 KB decode into a 20 KB +-- one plus a few milliseconds spread over the fight. +-- +-- AN LRU OF FOUR. A model is shared by everything that draws that species +-- -- both sides of a mirror match, both VR eyes -- and kept for a few +-- battles after, because the next fight on the same route is very often +-- the same Pokemon. Four is enough for a wild fight (two) plus the +-- trainer's next two, and it bounds what the mode can hold to a few +-- megabytes. +-- +-- Everything is pcall-guarded and every failure answers nil: a missing +-- pack, a truncated file or a driver that will not make an image all end +-- at the same place, which is the flat 2D-3D card this mode falls back to +-- (see Stadium). + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local StadiumPack = {} + +local byte = string.byte +local floor = math.floor + +-- ------- where a pack comes from +-- +-- Two places, asked in this order. +-- +-- CACHE_DIR is in the save directory and is what actually ships: the mod +-- carries no models (they are Pokemon Stadium's data), so StadiumInstall +-- builds them out of the player's own ROM on first run and writes them here. +-- +-- DIR is inside the mod, and exists for a developer checkout that has run +-- tools/stadium_pack.py -- which is also how the oracle the Lua extractor is +-- tested against gets built. It is second because a locally built cache +-- should win over whatever a checkout happens to have lying around. +StadiumPack.CACHE_DIR = "dramatic_shape/stadium" +StadiumPack.DIR = "assets/stadium" + +local function readPack(species) + local rel = ("%s/%03d.dsm"):format(StadiumPack.CACHE_DIR, species) + if love and love.filesystem and love.filesystem.getInfo then + local okInfo, info = pcall(love.filesystem.getInfo, rel, "file") + if okInfo and info then + local ok, bytes = pcall(love.filesystem.read, rel) + if ok and type(bytes) == "string" and #bytes > 4 then return bytes end + end + end + local mod = V.mod + if not (mod and mod.read) then return nil end + local ok, bytes = pcall(mod.read, mod, + ("%s/%03d.dsm"):format(StadiumPack.DIR, species)) + if ok and type(bytes) == "string" and #bytes > 4 then return bytes end + return nil +end + +-- The battle system's context slots, in the order tools/stadium_pack.py +-- writes them -- slot 165 upward (see model_extract/manifest.json's +-- animationSlots). Indexed by POSITION, so this list is the format's +-- contract and the packer's CONTEXTS must stay identical to it. +-- Position 2 was called "hit" until the move table was read against it: it +-- is the animation most of a species' MOVES play, which makes it the default +-- attack and not a damage reaction (see StadiumMon's STATES). The name is a +-- label on a position -- the format is the ORDER -- so renaming it changes no +-- bytes, but it has to match tools/stadium_pack.py's CONTEXTS. +StadiumPack.CONTEXT = { + "idle", "attack_default", "faint", "entrance", "reaction_169", "reaction_170", + "reaction_171", "reaction_172", "reaction_173", "reaction_174", + "struggle", "idle_alt", "faint_alt", "flinch", "reaction_179", + "reaction_180", "reaction_181", "reaction_182", "entrance_alt", + "idle_return", +} + +-- name -> slot position, for callers that ask by name +StadiumPack.SLOT = {} +for i, name in ipairs(StadiumPack.CONTEXT) do StadiumPack.SLOT[name] = i end + +StadiumPack.N_MOVES = 165 +StadiumPack.NONE = 0xFFFF + +-- The frame rate every animation in the set is authored at +-- (model_extract/README.md: keyframe times are frame / 30). +StadiumPack.FPS = 30 + +-- ------- readers +-- +-- One cursor threaded through by hand rather than an object: this runs over +-- a couple of hundred thousand values on the frame a battle starts, and a +-- method call per value is the difference between a hitch and no hitch. + +local function u8(s, p) return byte(s, p), p + 1 end + +local function u16(s, p) + local a, b = byte(s, p, p + 1) + return a + b * 256, p + 2 +end + +local function i16(s, p) + local a, b = byte(s, p, p + 1) + local v = a + b * 256 + if v >= 32768 then v = v - 65536 end + return v, p + 2 +end + +local function u32(s, p) + local a, b, c, d = byte(s, p, p + 3) + return a + b * 256 + c * 65536 + d * 16777216, p + 4 +end + +local function i32(s, p) + local v + v, p = u32(s, p) + if v >= 2147483648 then v = v - 4294967296 end + return v, p +end + +-- IEEE 754 single, by hand. LOVE has love.data.unpack, but this file reads +-- exactly four floats per model (the header's extents) and a hand decode +-- costs nothing while removing a version floor from the mod's whole +-- STADIUM path. +local function f32(s, p) + local b1, b2, b3, b4 = byte(s, p, p + 3) + local sign = 1 + if b4 >= 128 then sign, b4 = -1, b4 - 128 end + local expo = b4 * 2 + floor(b3 / 128) + local mant = (b3 % 128) * 65536 + b2 * 256 + b1 + if expo == 255 then + if mant == 0 then return sign * math.huge, p + 4 end + return 0, p + 4 + end + if expo == 0 then return sign * mant * 2 ^ -149, p + 4 end + return sign * (1 + mant / 8388608) * 2 ^ (expo - 127), p + 4 +end + +-- 16.16 fixed point, which is how bone scales are stored (they run from +-- about -31 to 100 across the set and a float would cost twice the bytes +-- for precision nothing can see). +local function fixed(s, p) + local v + v, p = i32(s, p) + return v / 65536, p +end + +-- ------- the load + +local function readHeader(s, p, model) + model.species, p = u16(s, p) + model.boneCount, p = u16(s, p) + model.primCount, p = u16(s, p) + model.texCount, p = u16(s, p) + model.animCount, p = u16(s, p) + model.auxCount, p = u16(s, p) + model.rootScale, p = f32(s, p) + -- a species whose standby loop is corrupt in the source extraction, and + -- which the mod therefore holds at its bind pose (see the packer's + -- idle_is_broken). Three of the 151. + local static + static, p = u8(s, p) + model.staticPose = static ~= 0 + model.height, p = f32(s, p) + model.floor, p = f32(s, p) + model.radius, p = f32(s, p) + + local moveAnim, moveAux, ctx = {}, {}, {} + for i = 1, StadiumPack.N_MOVES do moveAnim[i], p = u16(s, p) end + for i = 1, StadiumPack.N_MOVES do moveAux[i], p = i16(s, p) end + for i = 1, #StadiumPack.CONTEXT do ctx[i], p = u16(s, p) end + model.moveAnim, model.moveAux, model.ctx = moveAnim, moveAux, ctx + return p +end + +-- The bone tree, as flat parallel arrays: a rig walk touches every bone +-- every frame and an array of little tables would be a cache miss per bone +-- and a table per bone to collect. +local function readBones(s, p, model) + local n = model.boneCount + local parent, t, r, sc = {}, {}, {}, {} + for i = 1, n do + -- 0-based in the file, 1-based here, and 0 for "no parent" so the rig's + -- walk can test it without a sentinel comparison + local par + par, p = i16(s, p) + parent[i] = par + 1 + local b = (i - 1) * 3 + t[b + 1], p = i16(s, p) + t[b + 2], p = i16(s, p) + t[b + 3], p = i16(s, p) + r[b + 1], p = i16(s, p) + r[b + 2], p = i16(s, p) + r[b + 3], p = i16(s, p) + sc[b + 1], p = fixed(s, p) + sc[b + 2], p = fixed(s, p) + sc[b + 3], p = fixed(s, p) + end + model.parent, model.restT, model.restR, model.restS = parent, t, r, sc + return p +end + +-- One drawable piece: the triangles that share a texture and a cull mode. +-- +-- Positions and normals stay in BONE-LOCAL space, exactly as the display +-- list had them, because that is what makes the skinning a single matrix +-- multiply per vertex (every vertex in the set is rigidly bound to one bone +-- -- see model_extract/README.md) rather than a weighted blend. +local function readPrims(s, p, model) + local prims = {} + for i = 1, model.primCount do + local prim = {} + prim.tex, p = u16(s, p) + prim.tex = prim.tex + 1 + local cull, blend + cull, p = u8(s, p) + blend, p = u8(s, p) + prim.cull = cull ~= 0 + prim.additive = blend ~= 0 + prim.texAnim, p = i16(s, p) + + -- the texture-animation channel's value -> which texture to swap in. + -- Keyed by the stream's own byte, so the rig can look one up without + -- searching. + local mapN + mapN, p = u8(s, p) + if mapN > 0 then + local map = {} + for _ = 1, mapN do + local key, tex + key, p = u8(s, p) + tex, p = u16(s, p) + map[key] = tex + 1 + end + prim.texMap = map + end + + local fxN + fxN, p = u16(s, p) + if fxN > 0 then + local frames = {} + for k = 1, fxN do + frames[k], p = u16(s, p) + frames[k] = frames[k] + 1 + end + prim.fxFrames = frames + end + + local nv, ni + nv, p = u16(s, p) + ni, p = u16(s, p) + prim.vertCount, prim.indexCount = nv, ni + + -- five arrays rather than one array of vertices, for the same reason + -- the bones are flat: the skinning loop reads them in step and writes + -- one LOVE vertex row out + local px, py, pz = {}, {}, {} + local uv = {} + local nx, ny, nz = {}, {}, {} + local bone = {} + for k = 1, nv do + px[k], p = i16(s, p) + py[k], p = i16(s, p) + pz[k], p = i16(s, p) + local u, v + u, p = i16(s, p) + v, p = i16(s, p) + uv[k * 2 - 1], uv[k * 2] = u / 512, v / 512 + local a, b, c + a, p = u8(s, p) + b, p = u8(s, p) + c, p = u8(s, p) + if a >= 128 then a = a - 256 end + if b >= 128 then b = b - 256 end + if c >= 128 then c = c - 256 end + nx[k], ny[k], nz[k] = a / 127, b / 127, c / 127 + bone[k], p = u8(s, p) + bone[k] = bone[k] + 1 + end + prim.px, prim.py, prim.pz = px, py, pz + prim.uv = uv + prim.nx, prim.ny, prim.nz = nx, ny, nz + prim.bone = bone + + local idx = {} + for k = 1, ni do + idx[k], p = u16(s, p) + idx[k] = idx[k] + 1 + end + prim.index = idx + prims[i] = prim + end + model.prims = prims + return p +end + +-- The textures, kept as the raw RGBA8 they arrived as and turned into +-- images on first use. A species carries every frame of every blink and +-- every dizzy swirl; a fight that never shows one should not pay to +-- upload it. +-- +-- Raw rather than PNG, which is what DSM3 changed: an ImageData over these +-- bytes is a memcpy where a PNG is a decode on the frame a battle starts, +-- and -- the reason it was actually done -- uncompressed pixels are the same +-- pixels whichever side wrote them, so the Lua extractor's output can be +-- diffed against the Python packer's byte for byte. Two deflate +-- implementations need not agree; two arrays of pixels do. +local function readTextures(s, p, model) + local tex = {} + for i = 1, model.texCount do + local w, h, len + w, p = u16(s, p) + h, p = u16(s, p) + len, p = u32(s, p) + tex[i] = { w = w, h = h, rgba = s:sub(p, p + len - 1) } + p = p + len + end + model.textures = tex + return p +end + +-- How many bytes one animation's track block occupies, without decoding +-- any of it. This is the scan that makes lazy animations possible: nine +-- components a bone, each either one value or one a frame, and the only +-- thing that has to be READ is the byte that says which. +local COMP_BYTES = { 2, 2, 2, 2, 2, 2, 4, 4, 4 } -- t t t r r r s s s + +local function skipTracks(s, p, boneCount, frames) + for _ = 1, boneCount do + local present + present, p = u8(s, p) + if present ~= 0 then + for c = 1, 9 do + local kind + kind, p = u8(s, p) + p = p + COMP_BYTES[c] * (kind == 0 and 1 or frames) + end + end + end + return p +end + +local function readAnims(s, p, model) + local anims = {} + for i = 1, model.animCount do + local len + len, p = u8(s, p) + local name = s:sub(p, p + len - 1) + p = p + len + local frames, loopStart, aux + frames, p = u16(s, p) + loopStart, p = u16(s, p) + aux, p = i16(s, p) + anims[i] = { + name = name, frames = frames, loopStart = loopStart, + aux = aux >= 0 and (aux + 1) or nil, + seconds = frames / StadiumPack.FPS, + offset = p, -- where its tracks start; decoded later + } + p = skipTracks(s, p, model.boneCount, frames) + end + model.anims = anims + return p +end + +local function readAux(s, p, model) + local aux = {} + for i = 1, model.auxCount do + local frames, loopStart, chanN + frames, p = u16(s, p) + loopStart, p = u16(s, p) + chanN, p = u16(s, p) + local chans = {} + for c = 1, chanN do + local n + n, p = u16(s, p) + local stream = {} + for k = 1, n do stream[k], p = u16(s, p) end + chans[c] = stream + end + aux[i] = { frames = frames, loopStart = loopStart, channels = chans } + end + model.auxAnims = aux + return p +end + +-- ------- a track block, decoded on demand +-- +-- The shape a pose walk wants: `tracks[bone]` is either nil (this bone +-- holds its rest transform for the whole animation) or nine entries, each +-- either a number (constant) or an array of one value per frame. +-- +-- That fold is the source data's own, not something imposed here: a bone +-- that only rotates costs two bytes for each of its six other components, +-- and across the 151 species it is most of the reason the whole set is 24 +-- megabytes rather than a hundred. +function StadiumPack.tracks(model, index) + local anim = model.anims and model.anims[index] + if not anim then return nil end + if anim.tracks then return anim.tracks end + local s, p = model.bytes, anim.offset + if not (s and p) then return nil end + local frames = anim.frames + local out = {} + for b = 1, model.boneCount do + local present + present, p = u8(s, p) + if present ~= 0 then + local comps = {} + for c = 1, 9 do + local kind + kind, p = u8(s, p) + local read = (c >= 7) and fixed or i16 + if kind == 0 then + comps[c], p = read(s, p) + else + local arr = {} + for k = 1, frames do arr[k], p = read(s, p) end + comps[c] = arr + end + end + out[b] = comps + end + end + anim.tracks = out + return out +end + +-- One texture as a LOVE image, decoded on first ask. +function StadiumPack.image(model, index) + local slot = model.textures and model.textures[index] + if not slot then return nil end + if slot.image ~= nil then return slot.image or nil end + local ok, img = pcall(function() + local data = love.image.newImageData(slot.w, slot.h, "rgba8", slot.rgba) + local image = love.graphics.newImage(data) + -- N64 art at N64 resolution: nearest keeps the texels the size the + -- artist drew them, exactly as every other texture in this mode + image:setFilter("nearest", "nearest") + return image + end) + slot.image = (ok and img) or false + return slot.image or nil +end + +-- ------- the cache + +local cache = {} -- species -> model +local order = {} -- species, least recently used first +StadiumPack.KEEP = 4 + +local function touch(species) + for i = #order, 1, -1 do + if order[i] == species then table.remove(order, i) end + end + order[#order + 1] = species + while #order > StadiumPack.KEEP do + local drop = table.remove(order, 1) + local model = cache[drop] + cache[drop] = nil + if model and model.textures then + for _, slot in ipairs(model.textures) do + if slot.image and slot.image.release then + pcall(slot.image.release, slot.image) + end + -- CLEARED, not just released. A released Image is still a truthy + -- value, and `image()` below hands back whatever is in this field + -- without looking at it -- so leaving the corpse here meant the next + -- ask returned a dead object, which reached mesh:setTexture and threw + -- "Cannot use object after it has been released" from inside the + -- scene pass. Nil means the next ask decodes it again, which is the + -- whole point of the slot being lazy. + slot.image = nil + end + end + end +end + +-- Say that this species is IN USE, so the cache does not evict it. +-- +-- The eviction order above is a least-recently-LOADED list, not a +-- least-recently-used one: `touch` runs from `load`, and `load` is only +-- reached when a side's species CHANGES (StadiumMon.setSpecies returns early +-- otherwise). A Pokemon that stands on the field for several turns therefore +-- never refreshes its position, drifts to the front of the queue, and is +-- evicted -- its textures released -- while it is still being drawn sixty +-- times a second. That is what a fifth species entering a battle did: call +-- out a Clefairy and whatever had been standing longest lost its textures +-- mid-fight. +-- +-- So the mode says, every frame, which two species are actually standing +-- there (see Stadium.update). With KEEP at 4 and two sides, the two in use +-- are always the two most recent and cannot reach the front of the queue. +function StadiumPack.keep(species) + if species and cache[species] then touch(species) end +end + +-- Whether a pack for this species is on disk at all. Cheap enough to ask +-- before a battle commits to the mode, and the honest test: a mod +-- installed without its assets folder must decline rather than error. +function StadiumPack.available(species) + if cache[species] then return true end + return readPack(species) ~= nil +end + +-- The model for a National Dex number (1..151), or nil. +function StadiumPack.load(species) + if not (species and species >= 1 and species <= 151) then return nil end + local hit = cache[species] + if hit ~= nil then + touch(species) + return hit or nil + end + + local bytes = readPack(species) + if not bytes then + cache[species] = false + return nil + end + + local ok, model = pcall(function() + if bytes:sub(1, 4) ~= "DSM3" then + error("not a DSM3 pack -- delete it and let the mod rebuild it", 0) + end + local m = { bytes = bytes } + local p = 5 + p = readHeader(bytes, p, m) + p = readBones(bytes, p, m) + p = readPrims(bytes, p, m) + p = readTextures(bytes, p, m) + p = readAnims(bytes, p, m) + readAux(bytes, p, m) + return m + end) + if not ok then + V.mod.log:warn("stadium: %03d.dsm did not read: %s -- that Pokemon " + .. "falls back to its flat pic", species, tostring(model)) + cache[species] = false + return nil + end + + cache[species] = model + touch(species) + return model +end + +-- Drop everything (hot reload, or a graphics context that went away). +function StadiumPack.invalidate() + for _, model in pairs(cache) do + if model and model.textures then + for _, slot in ipairs(model.textures) do + if slot.image and slot.image.release then + pcall(slot.image.release, slot.image) + end + slot.image = nil + end + end + end +end + +function StadiumPack.forget() + StadiumPack.invalidate() + cache, order = {}, {} +end + +return StadiumPack diff --git a/lib/StadiumRig.lua b/lib/StadiumRig.lua new file mode 100644 index 0000000..bfc79f6 --- /dev/null +++ b/lib/StadiumRig.lua @@ -0,0 +1,834 @@ +-- STADIUM battles: posing a skeleton and skinning it, on the CPU. +-- +-- One instance of this is one Pokemon standing on the map -- the meshes it +-- draws through and the scratch space its pose is computed in. The MODEL +-- (geometry, bones, animations, textures) is shared and read-only; this is +-- everything about it that is per-Pokemon and changes every frame. +-- +-- ------- why the CPU +-- +-- Because these models are tiny and the mod's shader already exists. A +-- battle model is 674 vertices on average and 1311 at the worst, of which +-- exactly two are on screen at a time -- so skinning them by hand costs +-- about two thousand vertex transforms a frame, which is less than the +-- grass pass does on an empty route. What it buys is that the finished +-- vertices go into Voxel3D's OWN vertex format, through Voxel3D's OWN +-- shader, and therefore get every single thing the rest of the diorama +-- gets for free: the depth buffer decides what is in front of what, the +-- sun pass throws a real shadow of the actual pose, the hour's tint lands +-- on it, the hit flash flattens it, and the tilt-shift and the +-- depth-of-field see it as part of the picture. A GPU skinning path would +-- have needed a second shader that then had to re-implement all of that, +-- and a second shadow shader beside it. +-- +-- It is also what makes the FORMAT work. Every vertex in the Stadium set is +-- rigidly bound to ONE bone with weight 1 (model_extract/README.md), so +-- skinning is a single matrix multiply per vertex with no blend -- and the +-- per-vertex `shade` Voxel3D wants, which no glTF has, is computed here +-- from the bone-local normal. +-- +-- ------- the two matrix chains +-- +-- The game keeps bone scale OUT of the matrix chain (func_800143C0): scale +-- accumulates in its own stack, a bone's local translation is +-- pre-multiplied by its parent's accumulated scale, and a bone's own +-- accumulated scale is applied to the finished matrix only at draw time. +-- glTF cannot express that -- its node scale propagates to children -- and +-- the reference export works around it by splitting every bone into two +-- nodes. +-- +-- Here it falls out naturally, as two arrays: +-- +-- pivot rotation and translation only. This is what a CHILD inherits, +-- and it is a pure rotation, which is also why the normals are +-- transformed with it rather than with the draw matrix. +-- draw the same matrix with the bone's accumulated scale applied on +-- the right, which is the one vertices go through. +-- +-- Folding the scale into the chain instead is the obvious mistake and it +-- applies every ancestor's scale once per generation. It is caught by the +-- suite: tools/stadium_pack.py measures the bind pose with this exact walk +-- and its answer matches the verified glTF export on all 151 species. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local Voxel3D = V.require("Voxel3D") +local StadiumPack = V.require("StadiumPack") + +local StadiumRig = {} +StadiumRig.__index = StadiumRig + +local sin, cos, floor = math.sin, math.cos, math.floor + +-- binary angle (32768 = pi) to radians +local ANG = math.pi / 32768 + +-- ------- how a surface is lit +-- +-- Voxel3D shades a face by its DIRECTION rather than by a light uniform: +-- every terrain and character mesh in this mode carries a per-vertex +-- `shade` baked from which way its face points, and the shadow map +-- multiplies on top of that (see Voxel3D.FACE_SHADE). A skinned model has +-- no fixed faces to bake, so the same answer is computed per vertex from +-- the posed normal -- and these four numbers are FACE_SHADE's own six +-- values, fitted: +-- +-- +Y up 1.00 -Y down 0.55 +X east 0.84 -X west 0.72 +-- +Z south 0.90 -Z north 0.68 +-- +-- so a Pokemon's flank catches the same southeastern sun the roof of the +-- house behind it does, and the two read as being in one picture. +local SHADE_BASE = 0.7725 +local SHADE_X = 0.06 +local SHADE_Y = 0.225 +local SHADE_Z = 0.11 + +-- ------- an instance + +-- `model` is a StadiumPack model. Returns nil where meshes cannot be made, +-- which is the same "no 3D" answer every other GPU object in this mod gives. +function StadiumRig.new(model) + if not (model and model.prims) then return nil end + if not (love.graphics and love.graphics.newMesh) then return nil end + + local self = setmetatable({ + model = model, + -- The two chains, flat: twelve numbers a bone, row-major 3x4. + -- + -- Named with the M rather than `pivot` and `draw` because an instance + -- field called `draw` shadows the DRAW METHOD through __index, and the + -- failure that causes is a nasty one: the shadow pass calls caster() + -- and keeps working, so a Pokemon casts a perfect animated shadow onto + -- ground it is not standing on. + pivotM = {}, + drawM = {}, + -- the accumulated scale, which is the third thing the game's own walk + -- carries and neither matrix can hold + accX = {}, accY = {}, accZ = {}, + parts = {}, + -- what the pose walk last answered, so a frame that neither moved the + -- animation nor turned the model can skip the whole thing + poseKey = nil, + -- scratch for the body-centre estimate (see anchor), kept on the rig so + -- a per-frame measurement allocates nothing + cx = {}, cy = {}, cz = {}, + }, StadiumRig) + + -- One mesh per primitive: a primitive is already "the triangles sharing + -- one texture", which is exactly one draw call's worth. + -- + -- "dynamic" rather than "static": every vertex is rewritten every frame + -- the pose changes, which is what the usage hint exists to say. + for i, prim in ipairs(model.prims) do + local rows = {} + local uv = prim.uv + for k = 1, prim.vertCount do + -- position and shade are filled by skin(); the texture coordinates + -- never change, so they are written once here + rows[k] = { 0, 0, 0, uv[k * 2 - 1], uv[k * 2], 1 } + end + local ok, mesh = pcall(love.graphics.newMesh, Voxel3D.FORMAT, rows, + "triangles", "dynamic") + if not ok then return nil end + pcall(mesh.setVertexMap, mesh, prim.index) + self.parts[i] = { mesh = mesh, rows = rows, prim = prim } + end + -- the spot the animations are measured against, taken while there is no + -- pose to overwrite (see measureBind) + pcall(self.measureBind, self) + return self +end + +function StadiumRig:release() + for _, part in ipairs(self.parts or {}) do + if part.mesh and part.mesh.release then + pcall(part.mesh.release, part.mesh) + end + end + self.parts = {} +end + +-- ------- sampling one track +-- +-- `c` is the pack's own fold: a bare number when the component holds still +-- for the whole animation, or one value a frame when it does not. Two frame +-- indices and a blend come in because the caller has already resolved what +-- "between frame 12 and 13, three tenths of the way" means for THIS +-- animation's looping. + +-- One component at one frame. +local function sampleAt(c, i) + if type(c) == "number" then return c end + return c[i] +end + +-- ------- interpolation, and the one place it must not happen +-- +-- These streams are not keyframes: they carry ONE VALUE PER FRAME at 30 Hz, +-- and the game steps them a frame at a time. So at 60 Hz the honest replay +-- is each pose held for two frames -- which is exactly what it looks like, +-- a set of models moving at half the frame rate of everything around them. +-- Blending between consecutive entries is therefore not reconstructing +-- something the source had; it is INVENTING the halfway pose. It is worth +-- inventing, because a 30 Hz step against a 60 Hz camera reads as a stutter +-- and the halfway pose is right far more often than it is wrong. +-- +-- Where it IS wrong is the reason a naive version of this shipped once and +-- had to be taken out: bones snapping to an upside-down pose for a frame, +-- arms turning inside out for a few. Rotations here are EULER TRIPLES, and +-- a Euler triple is not a direction you can walk along. Two triples can +-- describe nearly the same orientation and be nowhere near each other +-- component by component -- (0, 20976, 32736) and (0, -19936, -5904) are a +-- real pair out of the set -- so walking from one to the other passes +-- through orientations that are nothing like either end. That is precisely +-- a bone flipping over and back inside one frame. +-- +-- Shortest-arc wrapping (below) fixes the easy half of that, where a +-- component crosses the +-pi seam. It cannot fix the hard half, where the +-- source simply RE-EXPRESSES a rotation. So the hard half is not fixed, it +-- is DETECTED: a bone whose rotation moves more than BREAK_ANGLE in a +-- single frame is not being animated, it is being re-expressed or snapped, +-- and that bone holds its frame instead of blending. Per bone and all three +-- components together, because the three are one rotation and blending two +-- of them while holding the third is its own wrong answer. +-- +-- The same guard, in the same spirit, for TRANSLATION: BREAK_MOVE of the +-- model's own height inside one frame is a teleport rather than a stride. +-- Scale needs none -- a linear blend of two scales lies between them, and +-- there is no way for that to be a pose neither end had. + +-- 32768 binary-angle units is pi, so this is a quarter turn in one 30 Hz +-- frame -- 2700 degrees a second. Nothing in the set genuinely moves that +-- fast; everything that reads as moving that fast is a re-expression. +local BREAK_ANGLE = 16384 + +-- and half the Pokemon's own height in one frame, which is fifteen body +-- heights a second +local BREAK_MOVE = 0.5 + +-- The signed distance from `c[i0]` to `c[i1]` the SHORT way round, for a +-- binary angle. Interpolating 32700 toward -32700 the long way spins the +-- bone most of a full turn inside one frame; the short way is 136 units, +-- which is what actually happened. +local function angleDelta(c, i0, i1) + if type(c) == "number" then return 0 end + local d = c[i1] - c[i0] + if d > 32768 then d = d - 65536 elseif d < -32768 then d = d + 65536 end + return d +end + +local function linearDelta(c, i0, i1) + if type(c) == "number" then return 0 end + return c[i1] - c[i0] +end + +-- ------- the pose +-- +-- `anim` is an index into model.anims (or nil for the bind pose), `frame` a +-- FLOAT frame in that animation's own 30 Hz timeline, and `wrap` whether +-- the far end joins back to loopStart (a standby loop) or holds on the last +-- frame (a faint). +function StadiumRig:pose(anim, frame, wrap) + local model = self.model + local n = model.boneCount + local tracks = anim and StadiumPack.tracks(model, anim) or nil + local frames = anim and model.anims[anim] and model.anims[anim].frames or 1 + + -- The two frames this instant falls between, and how far. `k` is 0 on + -- every whole frame, so a caller that steps in whole frames -- the test + -- suite, the blink probe -- sees exactly the frame it asked for. + local i0, i1, k = 1, 1, 0 + if tracks and frames > 1 then + local f = frame + if f < 0 then f = 0 end + local base = floor(f) + k = f - base + local loop = model.anims[anim].loopStart or 0 + if not (loop > 0 and loop < frames) then loop = 0 end + if base >= frames then + if wrap then + -- the far end joins back to loopStart, which is where the game's own + -- player sends the counter (func_80016FBC) + base = loop + (base - loop) % (frames - loop) + else + base = frames - 1 -- a faint holds where it fell + k = 0 + end + end + i0 = base + 1 + if i0 > frames then i0 = frames end + if i0 < 1 then i0 = 1 end + -- and the frame after it, which past the end of a loop is loopStart -- + -- the same seam the counter itself crosses. An animation that HOLDS + -- (a faint) has nothing after its last frame, so it blends with itself. + if i0 < frames then + i1 = i0 + 1 + elseif wrap then + i1 = loop + 1 + else + i1, k = i0, 0 + end + end + + -- The frame this animation is actually SHOWING, after the wrap or the + -- hold, 0-based -- the WHOLE frame, never the blend. A texture swap has no + -- halfway: an eye is open or it is shut, and a pupil interpolated toward a + -- swirl is not a thing the hardware could draw. So the skeleton runs at 60 + -- and the textures step at 30, which is what the game does with both. + -- Stashed rather than recomputed because the texture + -- animation is sampled at the very same frame (see textures) -- in the + -- game one counter drives both, and 73% of the paired animations in the + -- set are the same length as each other, which is what that looks like + -- from the outside. Two copies of this arithmetic would be two things to + -- keep in step; one number cannot drift from itself. + self.frameAt = i0 - 1 + + local parent = model.parent + local restT, restR, restS = model.restT, model.restR, model.restS + local pivot, drw = self.pivotM, self.drawM + local accX, accY, accZ = self.accX, self.accY, self.accZ + + -- how far a bone may travel in one frame before it is read as a teleport + -- rather than a stride. In the vertices' own RAW units, which is what the + -- tracks are in: model.height is measured after the model_root scale. + local moveBreak = nil + if k > 0 then + local root = model.rootScale + if not (root and root > 0) then root = 1 end + local h = (model.height or 0) / root + if h > 0 then moveBreak = h * BREAK_MOVE end + end + + for b = 1, n do + local o3 = (b - 1) * 3 + local tx, ty, tz, rx, ry, rz, kx, ky, kz + local comps = tracks and tracks[b] + if comps then + tx = sampleAt(comps[1], i0) + ty = sampleAt(comps[2], i0) + tz = sampleAt(comps[3], i0) + rx = sampleAt(comps[4], i0) + ry = sampleAt(comps[5], i0) + rz = sampleAt(comps[6], i0) + kx = sampleAt(comps[7], i0) + ky = sampleAt(comps[8], i0) + kz = sampleAt(comps[9], i0) + if k > 0 then + -- ROTATION, all three at once: a bone that snaps holds its frame, + -- and a bone that moves holds none of it (see BREAK_ANGLE) + local dx = angleDelta(comps[4], i0, i1) + local dy = angleDelta(comps[5], i0, i1) + local dz = angleDelta(comps[6], i0, i1) + if dx < 0 then dx = -dx end + if dy < 0 then dy = -dy end + if dz < 0 then dz = -dz end + if dx <= BREAK_ANGLE and dy <= BREAK_ANGLE and dz <= BREAK_ANGLE then + rx = rx + angleDelta(comps[4], i0, i1) * k + ry = ry + angleDelta(comps[5], i0, i1) * k + rz = rz + angleDelta(comps[6], i0, i1) * k + end + -- TRANSLATION, likewise together: the three are one offset + local mx = linearDelta(comps[1], i0, i1) + local my = linearDelta(comps[2], i0, i1) + local mz = linearDelta(comps[3], i0, i1) + local far = false + if moveBreak then + far = (mx > moveBreak or mx < -moveBreak) + or (my > moveBreak or my < -moveBreak) + or (mz > moveBreak or mz < -moveBreak) + end + if not far then + tx, ty, tz = tx + mx * k, ty + my * k, tz + mz * k + end + -- SCALE, which cannot land anywhere the two ends did not bracket + kx = kx + linearDelta(comps[7], i0, i1) * k + ky = ky + linearDelta(comps[8], i0, i1) * k + kz = kz + linearDelta(comps[9], i0, i1) * k + end + else + -- a bone this animation never touches keeps its rest transform + tx, ty, tz = restT[o3 + 1], restT[o3 + 2], restT[o3 + 3] + rx, ry, rz = restR[o3 + 1], restR[o3 + 2], restR[o3 + 3] + kx, ky, kz = restS[o3 + 1], restS[o3 + 2], restS[o3 + 3] + end + + local p = parent[b] + local pax, pay, paz = 1, 1, 1 + if p > 0 then pax, pay, paz = accX[p], accY[p], accZ[p] end + -- the parent's accumulated scale, applied to the CHILD's offset. This + -- is the whole of what the game does instead of propagating scale. + tx, ty, tz = tx * pax, ty * pay, tz * paz + + -- Rx * Ry * Rz in the game's own row-vector form (src/F420.c + -- func_8000F730), written out as the rows of a 3x3 + local ax, ay, az = rx * ANG, ry * ANG, rz * ANG + local sx, cx = sin(ax), cos(ax) + local sy, cy = sin(ay), cos(ay) + local sz, cz = sin(az), cos(az) + local m11, m12, m13 = cy * cz, sx * sy * cz - cx * sz, cx * sy * cz + sx * sz + local m21, m22, m23 = cy * sz, sx * sy * sz + cx * cz, cx * sy * sz - sx * cz + local m31, m32, m33 = -sy, sx * cy, cx * cy + + local o = (b - 1) * 12 + if p > 0 then + local q = (p - 1) * 12 + local a1, a2, a3, a4 = pivot[q + 1], pivot[q + 2], pivot[q + 3], pivot[q + 4] + local b1, b2, b3, b4 = pivot[q + 5], pivot[q + 6], pivot[q + 7], pivot[q + 8] + local c1, c2, c3, c4 = pivot[q + 9], pivot[q + 10], pivot[q + 11], pivot[q + 12] + pivot[o + 1] = a1 * m11 + a2 * m21 + a3 * m31 + pivot[o + 2] = a1 * m12 + a2 * m22 + a3 * m32 + pivot[o + 3] = a1 * m13 + a2 * m23 + a3 * m33 + pivot[o + 4] = a1 * tx + a2 * ty + a3 * tz + a4 + pivot[o + 5] = b1 * m11 + b2 * m21 + b3 * m31 + pivot[o + 6] = b1 * m12 + b2 * m22 + b3 * m32 + pivot[o + 7] = b1 * m13 + b2 * m23 + b3 * m33 + pivot[o + 8] = b1 * tx + b2 * ty + b3 * tz + b4 + pivot[o + 9] = c1 * m11 + c2 * m21 + c3 * m31 + pivot[o + 10] = c1 * m12 + c2 * m22 + c3 * m32 + pivot[o + 11] = c1 * m13 + c2 * m23 + c3 * m33 + pivot[o + 12] = c1 * tx + c2 * ty + c3 * tz + c4 + else + pivot[o + 1], pivot[o + 2], pivot[o + 3], pivot[o + 4] = m11, m12, m13, tx + pivot[o + 5], pivot[o + 6], pivot[o + 7], pivot[o + 8] = m21, m22, m23, ty + pivot[o + 9], pivot[o + 10], pivot[o + 11], pivot[o + 12] = m31, m32, m33, tz + end + + local ex, ey, ez = pax * kx, pay * ky, paz * kz + accX[b], accY[b], accZ[b] = ex, ey, ez + -- the bone's own accumulated scale, on the right: it scales the axes of + -- THIS bone's space and cannot reach the children, which is exactly the + -- game's draw-time application + drw[o + 1], drw[o + 2] = pivot[o + 1] * ex, pivot[o + 2] * ey + drw[o + 3], drw[o + 4] = pivot[o + 3] * ez, pivot[o + 4] + drw[o + 5], drw[o + 6] = pivot[o + 5] * ex, pivot[o + 6] * ey + drw[o + 7], drw[o + 8] = pivot[o + 7] * ez, pivot[o + 8] + drw[o + 9], drw[o + 10] = pivot[o + 9] * ex, pivot[o + 10] * ey + drw[o + 11], drw[o + 12] = pivot[o + 11] * ez, pivot[o + 12] + end +end + +-- ------- keeping the Pokemon on its own tile +-- +-- Stadium's animations MOVE the Pokemon, and they move it a long way. Half +-- the set's send-out entrances walk the body more than its own height off +-- the spot it started on; Dewgong's faint travels nearly ten body-heights, +-- and its entrance seven and a half. Every one of them ends exactly where it +-- began, because that game framed each Pokemon with a camera of its OWN that +-- followed the performance around a stage. +-- +-- This mode has one camera, solved to put two named map cells at two fixed +-- points in a 160x144 frame (BattleCam), and a Pokemon that travels seven +-- body-heights out of that frame is simply GONE -- which is what sending out +-- a Farfetch'd looked like: an empty tile for three and a half seconds, +-- while its animation played somewhere off to the left of the shot. +-- +-- So the bulk travel is taken back out. The pose is measured, and whatever +-- has carried the body further than `limit` from where the bind pose put it +-- is subtracted from every bone. +-- +-- ------- why a LIMIT and not an anchor +-- +-- Pinning the body outright would flatten the animations into mime: a lunge, +-- a hop, a recoil and a collapse are all the body moving, and they are the +-- part worth having. What breaks the shot is not motion, it is EXCURSION -- +-- and the two are told apart by how far. Inside the limit nothing is touched +-- at all, so the 83 species whose animations stay put are bit-for-bit what +-- they were; past it the excess alone is removed, so a big move still reads +-- as big and still comes back to the tile it left. +-- +-- ------- where the body IS, and why it is not the median +-- +-- The first version of this took the median bone origin, on the reasoning +-- that a handful of bones flung anywhere cannot move a median. True, and it +-- had a worse problem: a median is a RANK, and a rank flips. On a bird most +-- of the skeleton is wing, so as the wings beat, which bone sits at the +-- middle of the sorted list swaps between the up cluster and the down one -- +-- and the estimate jumps with it. Measured on Pidgey's standby loop the +-- median moved a tenth of a body-height between adjacent half-frames, and on +-- Pidgeot three whole body-heights. The anchor turns that straight into a +-- translation of the ENTIRE Pokemon, so the body counter-shook against its +-- own wings and the flapping read as twice its real speed. That is the +-- "Pidgey's wings flap super fast" this comment exists because of. +-- +-- The centre is now the bone origins averaged, WEIGHTED BY HOW MANY VERTICES +-- EACH BONE MOVES. That fixes both halves at once: +-- +-- * the weights are a property of the MESH, computed once and never +-- changing, so there is no rank to flip and no discontinuity available +-- to it -- the estimate is as smooth as the bones themselves +-- * a bone with little geometry on it barely counts, which is exactly the +-- robustness the median was for. Farfetch'd's trail is thirty vertices +-- on five bones -- 1.6% of the model -- so streaking three thousand +-- units out moves this by nothing worth measuring +-- +-- Against the median it is two to five times smoother on every species +-- tested and measures the same travel to within a few percent. + +-- How far the body estimate may move in ONE 30 Hz frame of a species' own +-- standby loop before that species is judged unmeasurable and left +-- unanchored (see measureBind). The fastest genuine motion in the set is +-- about a fifth of a body-height a frame; the one species that fails this +-- moves three. +StadiumRig.ANCHOR_STEADY = 0.5 + +-- Which context slot the standby loop is, without requiring StadiumPack -- +-- this module is below it and a require would be circular. Position 1 of +-- StadiumPack.CONTEXT, which is the format's own contract. +local IDLE_SLOT = 1 + +-- How much of the model each bone actually carries. Cached on the shared +-- model: it is a fact about the mesh, not about this instance. +local function boneWeights(model) + if model.boneW then return model.boneW, model.boneWTotal end + local w, total = {}, 0 + for b = 1, model.boneCount do w[b] = 0 end + for _, prim in ipairs(model.prims) do + local bone = prim.bone + for k = 1, prim.vertCount do + local b = bone[k] + if w[b] then w[b] = w[b] + 1; total = total + 1 end + end + end + model.boneW, model.boneWTotal = w, total + return w, total +end + +-- The body centre of the pose currently in drawM. +local function centre(self, n) + local model = self.model + local w, total = boneWeights(model) + if not (total > 0) then return nil end + local x, y, z = 0, 0, 0 + local d = self.drawM + for b = 1, n do + local q = w[b] + if q and q > 0 then + local o = (b - 1) * 12 + x = x + d[o + 4] * q + y = y + d[o + 8] * q + z = z + d[o + 12] * q + end + end + return x / total, y / total, z / total +end + +-- Where the BIND pose puts it -- the spot every animation is measured +-- against. Cached on the shared MODEL, because it is a fact about the model +-- and not about this instance of it. +-- +-- Called once, from new(), and deliberately not lazily from anchor(): taking +-- this measurement means POSING the bind pose, which would overwrite the +-- animated pose anchor() was called to correct. Doing it while the rig is +-- still being built is the one moment there is no pose to lose. +function StadiumRig:measureBind() + local model = self.model + if model.bindCX then return end + self:pose(nil, 0, false) + model.bindCX, model.bindCY, model.bindCZ = centre(self, model.boneCount) + + -- ------- and whether this species can be anchored at all + -- + -- Decided ONCE, per model, offline, by walking its standby loop and asking + -- how far the body estimate moves between one frame and the next. + -- + -- Everything the anchor does rests on that estimate being a description of + -- where the Pokemon is. For 147 species it is: the fastest real motion in + -- the set moves the body about a fifth of a body-height per 30 Hz frame. + -- Pidgeot's standby loop moves it THREE, because a few of its rotation + -- frames are junk (the worst data in the set, and a known issue in its own + -- right). There is no filter setting that both tracks a real excursion and + -- rejects that -- measured, at four time constants, either the excursions + -- came back or the shake did -- because the two are only a factor of + -- fifteen apart and a filter is a proportion. + -- + -- So a species whose own idle says its estimate cannot be trusted is not + -- anchored, and plays exactly as it did before the anchor existed: it + -- travels as far as its animation says, and it does not vibrate. One + -- species trading a framing problem for no problem beats 147 trading a + -- solved framing problem for a shake. + -- + -- Cheap: forty-odd poses on a model that is about to be posed sixty times + -- a second anyway. + local idle = model.ctx and model.ctx[IDLE_SLOT] + local anim = (idle and idle ~= 0xFFFF) and (idle + 1) or nil + local rec = anim and model.anims and model.anims[anim] + model.anchorOk = true + if rec and rec.frames and rec.frames > 1 then + local root = model.rootScale + if not (root and root > 0) then root = 1 end + local h = (model.height or 0) / root + if h > 0 then + local px, py, pz, worst = nil, nil, nil, 0 + for f = 0, rec.frames - 1 do + self:pose(anim, f, true) + local x, y, z = centre(self, model.boneCount) + if x and px then + local d = (((x - px) ^ 2 + (y - py) ^ 2 + (z - pz) ^ 2) ^ 0.5) / h + if d > worst then worst = d end + end + px, py, pz = x, y, z + end + if worst > StadiumRig.ANCHOR_STEADY then + model.anchorOk = false + V.mod.log:info("stadium: species %s moves its own body %.1f " + .. "body-heights in one frame of its standby loop -- " + .. "not anchoring it, the measurement cannot be " + .. "trusted", tostring(model.species), worst) + end + end + end + -- and leave the bind pose behind, not the last frame of the idle + self:pose(nil, 0, false) +end + +-- ------- and why the offset is SMOOTHED +-- +-- A better centre is not enough on its own. Any estimate that follows the +-- pose carries the pose's own frame-to-frame wobble into it, and the anchor +-- multiplies that up into a translation of the whole Pokemon -- so a species +-- whose source data is erratic (Pidgeot's standby loop has a few frames of +-- junk in it, and no estimator can smooth data that is genuinely wrong) +-- would shake bodily rather than in the one bone that is wrong. +-- +-- So the offset is low-passed. What the anchor is FOR is a slow excursion -- +-- a Pokemon swimming seven body-heights away over two seconds -- and that +-- survives a filter with this time constant untouched, while anything +-- oscillating frame to frame is flattened. The correction ends up describing +-- where the Pokemon has drifted TO, never how it is shaking on the way. +-- +-- HALF_LIFE is in seconds: the time the offset takes to close half of any +-- gap between where it is and where the pose says it should be. Short enough +-- that a real excursion is caught within a few frames of starting, long +-- enough that a 30 Hz wobble does not survive it. +StadiumRig.ANCHOR_HALF_LIFE = 0.05 + + +-- ------- what this does NOT fix, and why it stops here +-- +-- The filter is a proportion, so it divides the input wobble down rather than +-- bounding it -- and one species' data is bad enough to get through anyway. +-- Pidgeot's standby loop carries a few frames of junk rotation (the worst in +-- the set, and a known issue since before the anchor existed), which moves +-- the body estimate three body-heights inside a single frame; filtered, that +-- is still about three pixels a frame on a fourteen-pixel model. +-- +-- Two further mechanisms were built and MEASURED against the set, and both +-- were taken back out: +-- +-- a rate limit on the correction bounded the shake to a third of a pixel, +-- and cost so much tracking that 33 of the 148 entrances went back to +-- leaving the frame -- half the problem the anchor exists to solve +-- +-- a rate limit on the MEASUREMENT, to tell a spike from an excursion by +-- speed, could not separate them: the fastest real excursion (Dewgong's +-- entrance, five and a half body-heights a second) is close enough to +-- Pidgeot's sustained junk that any threshold either clipped Dewgong or +-- passed Pidgeot, and freezing on distrust made both worse +-- +-- So it stops here, at the setting that is right for the 147 species whose +-- data is not broken. Pidgeot is a data problem and belongs with the other +-- data problems in the CHANGELOG's Known section, not in this control loop: +-- the alternative was distorting every other Pokemon's animation to flatter +-- one whose source frames are wrong. + +-- Pull the pose back toward the tile. `limit` is in the Pokemon's own +-- body-heights; nil or a non-positive value leaves the pose exactly as posed. +-- `dt` is the frame's own delta; without one the offset is applied whole, +-- which is what a still (the QA sweep, a probe) wants. +function StadiumRig:anchor(limit, dt) + if not (limit and limit > 0) then return end + local model = self.model + local n = model.boneCount + -- the vertices are in RAW units, before the model_root scale that + -- model.height is measured after + local root = model.rootScale + if not (root and root > 0) then root = 1 end + local h = (model.height or 0) / root + if not (h > 0) then return end + + local bx, by, bz = model.bindCX, model.bindCY, model.bindCZ + if not bx then return end -- never measured; leave the pose alone + if model.anchorOk == false then return end -- and unmeasurable, at that + local x, y, z = centre(self, n) + if not x then return end + + local dx, dy, dz = x - bx, y - by, z - bz + local dist = (dx * dx + dy * dy + dz * dz) ^ 0.5 + local allow = limit * h + + -- what the pose alone asks for: the EXCESS beyond the limit, so what is + -- inside it stays and the motion keeps its shape + local ox, oy, oz = 0, 0, 0 + if dist > allow and dist > 0 then + local k = (dist - allow) / dist + ox, oy, oz = dx * k, dy * k, dz * k + end + + -- and then toward it rather than straight to it (see ANCHOR_HALF_LIFE), + -- and never faster than ANCHOR_RATE + if dt and dt > 0 then + local half = StadiumRig.ANCHOR_HALF_LIFE + local a = (half > 0) and (1 - 0.5 ^ (dt / half)) or 1 + if a > 1 then a = 1 end + local px, py, pz = self.anchorX or ox, self.anchorY or oy, self.anchorZ or oz + ox = px + (ox - px) * a + oy = py + (oy - py) * a + oz = pz + (oz - pz) * a + end + self.anchorX, self.anchorY, self.anchorZ = ox, oy, oz + if ox == 0 and oy == 0 and oz == 0 then return end + + local pivot, drw = self.pivotM, self.drawM + for b = 1, n do + local o = (b - 1) * 12 + pivot[o + 4] = pivot[o + 4] - ox + pivot[o + 8] = pivot[o + 8] - oy + pivot[o + 12] = pivot[o + 12] - oz + drw[o + 4] = drw[o + 4] - ox + drw[o + 8] = drw[o + 8] - oy + drw[o + 12] = drw[o + 12] - oz + end +end + +-- ------- the skin +-- +-- Every vertex through its one bone's draw matrix, and its normal through +-- the same bone's pivot (a pure rotation, so the normal survives a +-- non-uniformly scaled bone -- which several species have). +-- +-- `yaw` is the model matrix's own turn, and it is folded in HERE rather +-- than left to the matrix because the shade has to be computed against the +-- WORLD normal: a Pokemon turned to face its opponent has a differently lit +-- flank than one facing the camera, and the sun does not turn with it. +function StadiumRig:skin(yaw) + local cy, sy = cos(yaw or 0), sin(yaw or 0) + local drw, piv = self.drawM, self.pivotM + for _, part in ipairs(self.parts) do + local prim, rows = part.prim, part.rows + local px, py, pz = prim.px, prim.py, prim.pz + local nx, ny, nz = prim.nx, prim.ny, prim.nz + local bone = prim.bone + for k = 1, prim.vertCount do + local o = (bone[k] - 1) * 12 + local x, y, z = px[k], py[k], pz[k] + local row = rows[k] + row[1] = drw[o + 1] * x + drw[o + 2] * y + drw[o + 3] * z + drw[o + 4] + row[2] = drw[o + 5] * x + drw[o + 6] * y + drw[o + 7] * z + drw[o + 8] + row[3] = drw[o + 9] * x + drw[o + 10] * y + drw[o + 11] * z + drw[o + 12] + local ax, ay, az = nx[k], ny[k], nz[k] + local wx = piv[o + 1] * ax + piv[o + 2] * ay + piv[o + 3] * az + local wy = piv[o + 5] * ax + piv[o + 6] * ay + piv[o + 7] * az + local wz = piv[o + 9] * ax + piv[o + 10] * ay + piv[o + 11] * az + -- the model matrix's yaw, by hand: (x, z) turned, y untouched + row[6] = SHADE_BASE + SHADE_X * (cy * wx + sy * wz) + SHADE_Y * wy + + SHADE_Z * (cy * wz - sy * wx) + end + pcall(part.mesh.setVertices, part.mesh, rows) + end +end + +-- ------- which texture each part wears this frame +-- +-- The eyes. A primitive whose display list carried geo command 0x23 with a +-- channel index has its texture REPLACED every frame from a stream of +-- texture-table indices (src/18140.c func_800176DC) -- which is how every +-- Pokemon in the game blinks, and how a confused one gets swirls. glTF has +-- no channel for that, so the .glb files carry only the first frame; the +-- pack carries the streams. +-- +-- `aux` is an index into model.auxAnims (the stream set) and `frame` its +-- own frame counter, which runs independently of the skeletal one. +-- The eyes, and everything else a material swaps per frame. +-- +-- Sampled at the SKELETAL animation's own frame -- the one pose() just +-- resolved -- and CLAMPED past the end of the stream rather than wrapped. +-- Both halves of that matter, and getting either wrong is visible. +-- +-- The frame is the skeleton's because in the game a single counter drives +-- both; the data says so plainly, since 507 of the 691 paired animations in +-- the set have a texture animation exactly as long as the skeletal one it +-- rides with. +-- +-- The clamp is what the game's own sampler does (func_80017540 indexes the +-- stream and holds the last entry past its end), and it is the whole +-- difference between a blink and a twitch. Rattata's standby loop is forty +-- frames and its blink is FIVE -- `6 8 7 8 6`, open through closed and back. +-- Wrapped on the blink's own length that plays six times a second, which is +-- what it looked like. Clamped, the eye blinks once at the top of the loop +-- and stays open for the remaining thirty-five frames, so it blinks about +-- once a second and a half. +function StadiumRig:textures(aux) + local model = self.model + local anim = aux and model.auxAnims and model.auxAnims[aux] or nil + local frame = self.frameAt or 0 + for _, part in ipairs(self.parts) do + local prim = part.prim + local index = prim.tex + if anim and prim.texAnim and prim.texAnim >= 0 and prim.texMap then + local stream = anim.channels[prim.texAnim + 1] + local n = stream and #stream or 0 + if n > 0 then + local at = frame + 1 + if at > n then at = n end + if at < 1 then at = 1 end + local mapped = prim.texMap[stream[at]] + if mapped then index = mapped end + end + end + part.texture = StadiumPack.image(model, index) + end +end + +-- ------- the draw +-- +-- `model` here is the MODEL MATRIX -- where this Pokemon stands, how big +-- and which way round -- and `sunModel` the transform the shadow pass drew +-- it with, which for these is the same matrix (unlike a character's leaning +-- card; see Voxel3D.draw). +-- +-- Seams off for the whole of it: the voxel wireframe draws the integer +-- planes of a mesh's own model space, and these vertices are in the N64's +-- own units where an integer plane means nothing (see VoxelGrid). Glass off +-- for the same reason the sprite passes turn it off -- the mask's +-- coordinates belong to the tileset atlas, not to a Pokemon's texture. +function StadiumRig:draw(matrix, pull) + Voxel3D.seams(false) + Voxel3D.glass(false) + local additive = nil + for _, part in ipairs(self.parts) do + if part.prim.additive then + -- held back to a second pass so the flames composite over the body + -- rather than depth-fighting it + additive = additive or {} + additive[#additive + 1] = part + elseif part.texture then + Voxel3D.draw(part.mesh, part.texture, matrix, pull) + end + end + if additive then + Voxel3D.blend("add") + for _, part in ipairs(additive) do + if part.texture then + Voxel3D.draw(part.mesh, part.texture, matrix, pull) + end + end + Voxel3D.blend(nil) + end + Voxel3D.glass(true) + Voxel3D.seams(true) +end + +-- The same geometry as the SUN sees it: no camera-ward pull (a trick for +-- the view's own depth buffer, which would drag a shadow off its owner) and +-- through the shadow pass's own draw call. The generated flame prims are +-- skipped -- a fire casts light, not a shadow. +function StadiumRig:caster(shadowMap, matrix) + for _, part in ipairs(self.parts) do + if part.texture and not part.prim.additive then + shadowMap.draw(part.mesh, part.texture, matrix) + end + end +end + +return StadiumRig diff --git a/lib/StadiumRom.lua b/lib/StadiumRom.lua new file mode 100644 index 0000000..e57493f --- /dev/null +++ b/lib/StadiumRom.lua @@ -0,0 +1,314 @@ +-- STADIUM battles: getting at the Pokemon Stadium ROM. +-- +-- Byte order, the archive the battle models are packed into, the Yay0 +-- decompressor that unwraps each one, and the per-species battle tables. It +-- is a port of model_extract/pipeline/rom.py, function for function, and the +-- Python remains the reference: tools/stadium_pack.py drives that side and +-- tests/stadium_extract_test.lua diffs this side's finished packs against it +-- byte for byte. +-- +-- ------- why this exists in Lua at all +-- +-- The mod cannot ship the models. They are ROM data, so what ships is the +-- READER, and the player supplies the ROM -- exactly the arrangement the +-- engine itself already has for the Game Boy ROM it is a recompilation of +-- (src/import/RomImporter.lua). Everything from `baserom.z64` to +-- `assets/stadium/NNN.dsm` therefore has to happen here, on the machine, in +-- Lua, with no Python and no build step. +-- +-- ------- what makes that tractable +-- +-- Three steps, and none of them needs a decompilation toolchain: +-- +-- 1. BYTE ORDER. The three N64 dump conventions differ by a swap that is +-- detected from the magic word and undone once, on load. +-- 2. THE ARCHIVE. The segment at 0x920000 is a count and a table of +-- (offset, size) records. No compression at that level, no names. +-- 3. Yay0. Nintendo's LZ variant: a bitstream where a 1 copies a literal +-- byte and a 0 pulls a (distance, length) pair out of a side table. +-- Thirty lines, and the same thirty lines the Python has. +-- +-- Verified in the Python by decompressing all 215 entries and diffing against +-- what the decompilation's own `make init` produces: 215/215 identical. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local StadiumRom = {} + +local byte = string.byte +local char = string.char +local concat = table.concat +local sub = string.sub +local floor = math.floor + +-- ROM offsets, from pokestadium-us.yaml by way of pipeline/rom.py. +StadiumRom.POKEMON_MODELS = 0x920000 -- archive of the 215 battle models +StadiumRom.BATTLE_DATA = 0x70D3A0 -- per-species battle tables +StadiumRom.MAIN_ROM = 0x1000 -- main code segment ... +StadiumRom.MAIN_VRAM = 0x80000400 -- ... and where it lands in RAM +StadiumRom.PTR_TABLE_VRAM = 0x80075BD0 -- D_80075BD0[species - 1] + +-- The revision every offset above is keyed to. A different ROM still runs -- +-- it may well be a regional variant with the same layout -- but the caller is +-- told, because "the models came out as garbage" and "that is not the ROM +-- this was written against" are the same fact and only one of them is useful. +StadiumRom.US_MD5 = "ed1378bc12115f71209a77844965ba50" + +-- The battle table's shape: 0xB90 bytes a species, as 0x10-byte entries. +-- Entries 0..164 are the moves (entry n drives move n + 1) and 165 up are the +-- fixed battle contexts. +StadiumRom.STRIDE = 0xB90 +StadiumRom.ENTRY = 0x10 +StadiumRom.N_MOVES = 165 + +-- How many of the archive's 215 models are the battle Pokemon. The rest are +-- props and trophies with no battle table. +StadiumRom.N_POKEMON = 151 + +-- ------- byte order +-- +-- .z64 is big-endian and native; .v64 has each pair of bytes swapped; .n64 +-- has each word reversed. `gsub` with a capture-reversing replacement does +-- either in one call through C rather than a Lua loop over 33 million bytes. + +local MAGIC_Z64 = "\128\055\018\064" +local MAGIC_V64 = "\055\128\064\018" +local MAGIC_N64 = "\064\018\055\128" + +-- Normalise a dump to .z64 order, or nil when it is not an N64 ROM at all. +function StadiumRom.normalise(bytes) + if type(bytes) ~= "string" or #bytes < 0x1000 then return nil end + local magic = sub(bytes, 1, 4) + if magic == MAGIC_Z64 then return bytes end + if magic == MAGIC_V64 then return (bytes:gsub("(.)(.)", "%2%1")) end + if magic == MAGIC_N64 then + return (bytes:gsub("(.)(.)(.)(.)", "%4%3%2%1")) + end + return nil +end + +-- ------- Yay0 +-- +-- The output has to be RANDOM ACCESS while it is being written -- a back +-- reference copies from what has already been produced, and overlapping runs +-- are legal and common -- so it is built in a flat table of byte values and +-- turned into a string at the end. +-- +-- The string.char conversion is the part that wants care: it is variadic and +-- has an argument limit, so the table is walked in blocks and the blocks +-- concatenated. Blocks of 4096 keep the call count and the intermediate +-- string count both low; the whole 151-model set converts in well under a +-- second on LuaJIT, which is what made an FFI buffer unnecessary here and +-- kept this module portable to any Lua the engine runs on. + +local CHUNK = 4096 + +-- LuaJIT keeps `unpack` global; 5.2+ moved it onto table. +local unpack = unpack or table.unpack + +local function bytesToString(out, n) + if n == 0 then return "" end + local parts, np = {}, 0 + local i = 1 + while i <= n do + local j = i + CHUNK - 1 + if j > n then j = n end + np = np + 1 + parts[np] = char(unpack(out, i, j)) + i = j + 1 + end + return concat(parts) +end + +-- Nintendo Yay0. Header: magic, decompressed size, link table offset, chunk +-- offset; then a bitstream read a word at a time. +function StadiumRom.yay0(src, base) + base = base or 0 + if sub(src, base + 1, base + 4) ~= "Yay0" then return nil, "not Yay0" end + local function be32(o) + local a, b, c, d = byte(src, base + o + 1, base + o + 4) + return ((a * 256 + b) * 256 + c) * 256 + d + end + local size = be32(4) + -- all three cursors are 1-based indices into `src`; the mask stream starts + -- immediately after the 16-byte header + local maskP = base + 0x10 + 1 + local linkP = base + be32(8) + 1 + local chunkP = base + be32(12) + 1 + + local out = {} + local pos = 0 -- bytes produced so far + local mask, bits = 0, 0 + + while pos < size do + if bits == 0 then + local a, b, c, d = byte(src, maskP, maskP + 3) + mask = ((a * 256 + b) * 256 + c) * 256 + d + maskP = maskP + 4 + bits = 32 + end + if mask >= 0x80000000 then + pos = pos + 1 + out[pos] = byte(src, chunkP) + chunkP = chunkP + 1 + else + local a, b = byte(src, linkP, linkP + 1) + linkP = linkP + 2 + local link = a * 256 + b + local dist = link % 0x1000 + local count = floor(link / 0x1000) + if count == 0 then + count = byte(src, chunkP) + 0x12 + chunkP = chunkP + 1 + else + count = count + 2 + end + -- overlapping runs are legal: copying one byte at a time from the + -- output as it grows is the behaviour, not a naive version of it + local copy = pos - dist + for _ = 1, count do + pos = pos + 1 + out[pos] = out[copy] + copy = copy + 1 + end + end + mask = (mask * 2) % 0x100000000 + bits = bits - 1 + end + + return bytesToString(out, size) +end + +-- Unwrap whatever container an asset arrived in. The model archive's entries +-- are PERS-SZP: an eight-byte magic plus a header size, wrapping a Yay0 +-- stream. +function StadiumRom.decompress(blob) + if sub(blob, 1, 8) == "PERS-SZP" then + local a, b, c, d = byte(blob, 9, 12) + local header = ((a * 256 + b) * 256 + c) * 256 + d + return StadiumRom.yay0(blob, header) + end + if sub(blob, 1, 4) == "Yay0" then return StadiumRom.yay0(blob, 0) end + return blob +end + +-- ------- the ROM + +local Rom = {} +Rom.__index = Rom + +-- `bytes` is the whole file. Returns the ROM, or nil plus why. +function StadiumRom.open(bytes) + local data = StadiumRom.normalise(bytes) + if not data then return nil, "not an N64 ROM (bad magic)" end + return setmetatable({ data = data }, Rom) +end + +function Rom:u8(o) + return byte(self.data, o + 1) +end + +function Rom:u32(o) + local a, b, c, d = byte(self.data, o + 1, o + 4) + if not d then return 0 end + return ((a * 256 + b) * 256 + c) * 256 + d +end + +function Rom:vramToRom(vram) + return StadiumRom.MAIN_ROM + (vram - StadiumRom.MAIN_VRAM) +end + +-- The md5 of the normalised image, or nil where LOVE's hash is not there +-- (the headless suite). Only ever used to tell the player which ROM they +-- gave us, never to refuse one. +function Rom:md5() + if self.hash ~= nil then return self.hash or nil end + local ok, hex = pcall(function() + local digest = love.data.hash("md5", self.data) + if type(digest) == "userdata" and digest.getString then + digest = digest:getString() + end + return love.data.encode("string", "hex", digest) + end) + self.hash = (ok and hex) or false + return self.hash or nil +end + +function Rom:isExpectedUS() + local hex = self:md5() + return hex == nil or hex == StadiumRom.US_MD5 +end + +-- ------- the archive +-- +-- Segments that hold many files start with +-- u32 tag, u32 0, u32 totalSize, u32 fileCount +-- followed by fileCount { u32 offset, u32 size, u32 pad[2] } records, all +-- relative to the start of the segment. +-- +-- Only the top three bytes of the first word are reliably zero: the model +-- archive puts a nonzero value in the low byte, which is the same quirk the +-- decompilation's own tools/unpack_asset.py works around. +-- +-- Returns a list of { start, size } rather than the bytes, so nothing is +-- copied until a caller actually wants a file. +function Rom:archive(off) + local tag = self:u32(off) + if (tag - tag % 256) ~= 0 or self:u32(off + 4) ~= 0 then return nil end + local count = self:u32(off + 12) + if count <= 0 or count >= 4096 then return nil end + local out = {} + for i = 0, count - 1 do + local rec = off + 0x10 + i * 0x10 + out[i + 1] = { start = off + self:u32(rec), size = self:u32(rec + 4) } + end + return out +end + +-- The entries of the battle-model archive, uncopied. +function Rom:models() + if not self.modelDir then + self.modelDir = self:archive(StadiumRom.POKEMON_MODELS) or {} + end + return self.modelDir +end + +function Rom:modelCount() + return #self:models() +end + +-- One model fragment, decompressed. `fileno` is 0-based, as in the Python and +-- in the source-file names: `N.bin` holds species N + 1. +function Rom:model(fileno) + local rec = self:models()[fileno + 1] + if not rec then return nil end + return StadiumRom.decompress(sub(self.data, rec.start + 1, + rec.start + rec.size)) +end + +-- ------- the per-species battle tables +-- +-- func_84302658 in src/fragments/62 DMAs 0xB90 bytes a species out of the +-- 0x70D3A0 segment, addressed through the D_80075BD0 pointer table. Byte 0 of +-- each 0x10-byte entry indexes that Pokemon's animation list and byte 1 its +-- auxiliary (texture) animation list. +-- +-- Returns a 0-based array-like table of { anim, aux }, aux 0xFF meaning none +-- and coming back as -1 -- the shape the packer writes. +function Rom:battleRows(species) + local ptrTable = self:vramToRom(StadiumRom.PTR_TABLE_VRAM) + local raw = self:u32(ptrTable + (species - 1) * 4) + local o = StadiumRom.BATTLE_DATA + raw % 0x1000000 + local rows = {} + local n = StadiumRom.STRIDE / StadiumRom.ENTRY + for e = 0, n - 1 do + local anim = self:u8(o + e * StadiumRom.ENTRY) + local aux = self:u8(o + e * StadiumRom.ENTRY + 1) + rows[e] = { anim, aux == 0xFF and -1 or aux } + end + rows.n = n + return rows +end + +return StadiumRom diff --git a/lib/StadiumRomPick.lua b/lib/StadiumRomPick.lua new file mode 100644 index 0000000..a99d583 --- /dev/null +++ b/lib/StadiumRomPick.lua @@ -0,0 +1,309 @@ +-- STADIUM battles: importing the ROM, instead of being told where to put it. +-- +-- The mod ships no Pokemon Stadium models and cannot -- they are that game's +-- data -- so the player supplies the cartridge. The original instruction for +-- that was "make a folder called baseroms next to the game and drop the file +-- in it", which is a fine sentence to write and a poor thing to ask. It needs +-- a folder the player has to create, in a place that is different on every +-- platform and is inside an unwritable archive on a packaged build, and it +-- fails SILENTLY: the two STADIUM rungs are simply not on the row, and +-- nothing on screen says why. +-- +-- So this opens a file picker instead, from a row on the OPTIONS menu, and +-- the folder keeps working for anyone who prefers it (StadiumInstall). +-- +-- ------- the picker is the host's, not LOVE's +-- +-- LOVE 11.5 has no file dialog. love.window.showFileDialog arrived in 12 and +-- love.system.pickFile is a native bridge this project ships for mobile +-- rather than part of LOVE at all. What every desktop OS does have is a +-- dialog reachable from a shell, so that is what is used here -- osascript on +-- macOS, PowerShell's OpenFileDialog on Windows, zenity then kdialog on +-- Linux. +-- +-- This is deliberately the SAME four commands the engine's own ROM importer +-- uses for the Game Boy cartridge (src/import/RomImporter.lua's chooseRom), +-- down to writing the Windows pick as UTF-8 -- the console's OEM codepage +-- mangles a non-ASCII path into something that crashes the next text draw. +-- Being a second copy of that is worth it: a mod cannot call into the +-- importer's private helpers, and the alternative is asking the engine to +-- grow a seam for one caller. +-- +-- The dialog BLOCKS. io.popen waits for the player to choose, and the game is +-- frozen for as long as it is up. That is what the engine's importer does +-- too, it is what a modal dialog means, and the frame it freezes on is an +-- options menu. +-- +-- ------- and the ROM is not kept +-- +-- The picked file is read, built from, and forgotten -- nothing is copied +-- anywhere. A Stadium cartridge is 32 MB and the models built out of it are +-- 34, so keeping both would double the cost of a feature for a file that has +-- no further use: the packs are what the game reads afterwards, and the +-- marker records the ROM's md5 so a swapped cartridge is still noticed. +-- +-- The one thing that costs is a format bump, which invalidates the packs and +-- leaves nothing to rebuild from. That is what the row still being there is +-- for -- it reads READY, and pressing it imports again. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local StadiumInstall = V.require("StadiumInstall") + +local StadiumRomPick = {} + +StadiumRomPick.LABEL = "STADIUM ROM" +StadiumRomPick.ID = "DRAMATIC_SHAPE:stadiumRom" + +-- Names the REVISION, because that is the thing a player gets wrong: the +-- model offsets are keyed to US 1.0 and nothing else is going to work. +local PROMPT = "Choose your Pokemon Stadium (US) 1.0 ROM" + +-- ------- the host, at arm's length +-- +-- Everything below is read through pcall and a presence test. The mod loader +-- hands a mod the real `io` and `os` today, but a mod that TAKES that for +-- granted is one that stops loading the day a sandbox arrives -- and this is +-- a convenience on top of a folder scan that works without any of it. + +local function haveShell() + local ok, popen = pcall(function() return io and io.popen end) + return (ok and popen) and true or false +end + +local function haveFiles() + local ok, open = pcall(function() return io and io.open end) + return (ok and open) and true or false +end + +local function osName() + local ok, name = pcall(function() return love.system.getOS() end) + return ok and name or nil +end + +-- Run a command and return its trimmed stdout, or nil for anything that did +-- not produce a line -- a cancelled dialog, a missing zenity, a shell that +-- is not there. +local function commandOutput(cmd) + if not haveShell() then return nil end + local ok, pipe = pcall(io.popen, cmd) + if not (ok and pipe) then return nil end + local okRead, out = pcall(pipe.read, pipe, "*a") + pcall(pipe.close, pipe) + if not (okRead and type(out) == "string") then return nil end + out = out:gsub("^%s+", ""):gsub("%s+$", "") + return (out ~= "") and out or nil +end + +-- ------- can this machine open a DIALOG +-- +-- Desktop only, and honestly so. +-- +-- On ANDROID the picker is a native bridge (love.system.pickFile) whose +-- kind -> filename mapping is a fixed list of three in the engine's own C++, +-- and an unrecognised kind falls through to `picked_rom.gb`. That is not +-- merely the wrong name -- it is the file the engine's Game Boy importer is +-- watching, and reading that code settles it: the importer's size test only +-- SKIPS a 1 MB file it has already imported, so a 32 MB N64 ROM landing +-- there falls straight through to `love.filesystem.remove` and +-- `startData` -- deleted, and then reported to the player as a broken Game +-- Boy ROM. So the bridge is not called until it learns the kind, which is a +-- two-line change in System.cpp and an APK rebuild (see README). +-- +-- Android is not stuck without it: conf.lua points the save directory at the +-- app's external-files folder, so `baseroms/` there is reachable over USB or +-- any file manager with no root and no permission prompt. What Android +-- lacked was being TOLD that -- the row vanished, and the folder's absolute +-- path was only ever written to a console no phone shows. That is what the +-- note below is for. +function StadiumRomPick.canDialog() + if not (haveShell() and haveFiles()) then return false end + local p = osName() + return p == "Windows" or p == "OS X" or p == "Linux" +end + +-- Kept as the old name for callers that only wanted "is there a dialog". +StadiumRomPick.available = StadiumRomPick.canDialog + +-- Where a SAF pick would land if the native bridge grows a Stadium kind. +-- Watched unconditionally (see poll): on a build that never writes it this +-- costs one getInfo a frame, and on one that does the mod needs no further +-- change to use it. +StadiumRomPick.PICKED = "picked_stadium.z64" + +-- Open the dialog. Returns the chosen absolute path, or nil when the player +-- cancelled or no dialog could be opened. +function StadiumRomPick.choose() + local p = osName() + if p == "OS X" then + return commandOutput( + ([[osascript -e 'POSIX path of (choose file with prompt "%s" of type ]] + .. [[{"z64", "n64", "v64"})' 2>/dev/null]]):format(PROMPT)) + elseif p == "Windows" then + local script = table.concat({ + "Add-Type -AssemblyName System.Windows.Forms;", + "$d=New-Object System.Windows.Forms.OpenFileDialog;", + "$d.Title='" .. PROMPT .. "';", + "$d.Filter='Nintendo 64 ROM (*.z64;*.n64;*.v64)|*.z64;*.n64;*.v64" + .. "|All files (*.*)|*.*';", + -- as UTF-8: the console's OEM codepage would mangle a non-ASCII path + -- and crash the next text draw that showed it + "if($d.ShowDialog() -eq 'OK'){[Console]::OutputEncoding=" + .. "[Text.Encoding]::UTF8; [Console]::Write($d.FileName)}", + }) + return commandOutput( + 'powershell -NoProfile -STA -Command "' .. script .. '"') + elseif p == "Linux" then + local path = commandOutput( + ([[zenity --file-selection --title="%s" ]] + .. [[--file-filter="Nintendo 64 ROM | *.z64 *.n64 *.v64" 2>/dev/null]]) + :format(PROMPT)) + if path then return path end + -- zenity is absent on plenty of installs (and on most handheld Linux + -- distributions); KDE's own dialog is the usual second answer + return commandOutput( + [[kdialog --getopenfilename "$HOME" "*.z64 *.n64 *.v64|]] + .. [[Nintendo 64 ROM" 2>/dev/null]]) + end + return nil +end + +-- Read an ABSOLUTE path, which love.filesystem cannot: it only sees inside +-- the physfs mount, and a picked file is anywhere on the disk. Returns the +-- bytes, or nil plus a reason short enough to fit the loading screen. +function StadiumRomPick.read(path) + if not haveFiles() then return nil, "no file access" end + local ok, fp = pcall(io.open, path, "rb") + if not (ok and fp) then return nil, "could not open that file" end + local okRead, bytes = pcall(fp.read, fp, "*a") + pcall(fp.close, fp) + if not (okRead and type(bytes) == "string" and #bytes > 0) then + return nil, "could not read that file" + end + return bytes +end + +-- ------- the whole flow, from one keypress +-- +-- Pick, read, start the build, and put the loading screen up over whatever +-- asked -- which is the OPTIONS menu, so the row is there again underneath +-- when the build finishes and now reads READY. +-- +-- A CANCELLED dialog is not a failure and says nothing: the player opened a +-- file browser and changed their mind, and a mod that made an announcement +-- about that would be the second most annoying thing on the menu. +-- +-- Everything else lands on the loading screen's own failure state, because it +-- is the one surface in this mode with room for a sentence -- and because a +-- player who has just chosen the wrong file is owed a reason and not a row +-- that quietly goes on saying IMPORT. +function StadiumRomPick.import(game) + if StadiumInstall.status.state == "building" then return false end + local StadiumScreen = V.require("StadiumScreen") + + -- No dialog on this platform: say where the file goes, on screen, because + -- that is the whole of what the player is missing and the console is not + -- somewhere they can read it. + if not StadiumRomPick.canDialog() then + if game and game.stack then + game.stack:push(StadiumScreen.newNote(game, "STADIUM ROM", + "PUT STADIUM US 1.0 HERE:", + StadiumInstall.romHintFile())) + end + return false + end + + local path = StadiumRomPick.choose() + if not path then return false end + local function fail(why) + StadiumInstall.status.state = "failed" + StadiumInstall.status.error = why + if game and game.stack then + game.stack:push(StadiumScreen.new(game, true)) + end + return false + end + + local bytes, err = StadiumRomPick.read(path) + if not bytes then return fail(err or "could not read that file") end + + local ok, beginErr = StadiumInstall.beginFrom(bytes, path) + if not ok then return fail(tostring(beginErr)) end + if game and game.stack then + game.stack:push(StadiumScreen.new(game, true)) + end + return true +end + +-- ------- the row +-- +-- An ACTION rather than a value, which is why it is not a ModSetting: there +-- is no rung to store, nothing for the mod manager's page to persist, and +-- nothing to restore on the next boot. What it shows is a STATE -- the models +-- are there or they are not -- and what it does is the only thing it can do. +-- +-- Still offered once they ARE there, reading READY. Pressing it imports +-- again, which is how a player swaps to a different revision, and how they +-- rebuild after a format bump has invalidated the packs and left nothing on +-- disk to rebuild from (see the header: the ROM is not kept). +-- +-- nil where no dialog can be opened, which takes the row off the menu +-- entirely rather than offering a button that cannot do anything. +function StadiumRomPick.row() + return { + id = StadiumRomPick.ID, + label = StadiumRomPick.LABEL, + value = function() + if StadiumInstall.status.state == "building" then return "BUILDING" end + if StadiumInstall.available() then return "READY" end + -- WHERE, not IMPORT, where pressing it can only tell you the folder: + -- a row that says IMPORT and then does not import is a worse row than + -- one that says what it actually does + return StadiumRomPick.canDialog() and "IMPORT" or "WHERE?" + end, + step = function(game) + pcall(StadiumRomPick.import, game) + return true + end, + } +end + +-- ------- a pick that landed while we were not looking +-- +-- The desktop dialog BLOCKS, so `import` above can read the answer on the +-- next line. A SAF pick cannot work that way: it is a separate activity, +-- Android is free to destroy the game while it is up, and the file appears +-- some frames later -- so the only way to notice one is to look for it. +-- +-- Nothing writes this filename today (see canDialog). It is watched anyway so +-- that teaching the native bridge one more kind is the whole of the Android +-- picker work, with no second change needed here. +-- +-- Consumed and DELETED either way: a 32 MB file left in the save directory +-- would be imported again on the next boot, and kept forever if the import +-- failed. +function StadiumRomPick.poll(game) + local f = love and love.filesystem + if not (f and f.getInfo) then return false end + if StadiumInstall.status.state == "building" then return false end + local ok, info = pcall(f.getInfo, StadiumRomPick.PICKED, "file") + if not (ok and info) then return false end + + local okRead, bytes = pcall(f.read, StadiumRomPick.PICKED) + pcall(f.remove, StadiumRomPick.PICKED) + if not (okRead and type(bytes) == "string") then return false end + + local StadiumScreen = V.require("StadiumScreen") + local started, err = StadiumInstall.beginFrom(bytes, StadiumRomPick.PICKED) + if not started then + StadiumInstall.status.state = "failed" + StadiumInstall.status.error = tostring(err) + end + if game and game.stack then + game.stack:push(StadiumScreen.new(game, true)) + end + return true +end + +return StadiumRomPick diff --git a/lib/StadiumScreen.lua b/lib/StadiumScreen.lua new file mode 100644 index 0000000..23b8202 --- /dev/null +++ b/lib/StadiumScreen.lua @@ -0,0 +1,391 @@ +-- STADIUM battles: the one-time build, on screen. +-- +-- A pushed game state, so it draws in the Game Boy's own 160x144 and stops +-- everything under it -- which is what it should do, because it is doing real +-- work and the player should not be walking around while it happens. +-- +-- ------- why a species a frame +-- +-- A species takes roughly fifty milliseconds to extract, and there are 151 of +-- them. That is ten seconds, which has to go somewhere. Doing them one per +-- frame puts the whole cost on this screen where it is explained, keeps the +-- bar moving at a visible rate, and leaves the frame free to draw between +-- them. Batching more per frame would finish no sooner -- the work is the +-- same -- and would only make the bar jump. +-- +-- ------- what it says +-- +-- Three things, and each of them is answering a question the player would +-- otherwise have to guess at while the game sits there: +-- +-- WHAT is happening -- "STADIUM EXTRACTION", which is what it is. +-- +-- HOW FAR through it is -- a bar, filled by species written rather than by +-- elapsed time, so it cannot lie about the remaining work. +-- +-- THAT IT IS ALIVE -- which Pokemon it is on, by name. Not decoration: it +-- is the difference between a progress bar the player trusts and one they +-- suspect has hung, and it costs one lookup a frame. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local StadiumInstall = V.require("StadiumInstall") + +local StadiumScreen = {} +StadiumScreen.__index = StadiumScreen + +-- The Game Boy frame this draws in. +local W, H = 160, 144 + +-- How long the finished message stays up before the screen retires itself. +StadiumScreen.HOLD = 1.1 + +local Font = nil +local function font() + if Font then return Font end + local ok, F = pcall(require, "src.render.Font") + if ok then Font = F end + return Font +end + +-- Black glyphs, because that is the only colour the Game Boy font sheets +-- have -- they are black on transparent, so setColor cannot lighten one. +-- Everything here is therefore laid out dark-on-light. +local function text(str, x, y) + local F = font() + if not F then return end + love.graphics.setColor(0, 0, 0, 1) + F.draw(str, math.floor(x), math.floor(y)) +end + +local function centred(str, y) + local F = font() + if not F then return end + text(str, (W - F.width(str)) / 2, y) +end + +-- How many glyphs fit across the frame. The font is a fixed eight pixels, so +-- twenty is the line -- and a centred string longer than that does not +-- overflow tidily off one side, it clips off BOTH and loses its first word as +-- well as its last ("that is not a Pokemon Stadium ROM" came out as "at is +-- not a Pokemon"). +-- +-- Fixed, and it stays fixed: shrinking the text to fit more in was tried and +-- the font will not take it. These are 1-bit 8x8 bitmaps, so a fractional +-- downscale drops whole pixel rows out of every glyph -- at 0.75 the last +-- line of an Android save path came out as mush. Long strings get more LINES +-- instead (see the note layout in draw). +local COLS = 20 + +-- Break a string into lines that fit, on word boundaries, and never more than +-- `limit` of them. +local function wrapped(str, limit, cols) + limit = limit or 2 + cols = cols or COLS + local lines, line = {}, nil + local function push(text) + if #lines < limit then lines[#lines + 1] = text end + end + for word in tostring(str):gmatch("%S+") do + local try = line and (line .. " " .. word) or word + if #try <= cols then + line = try + else + if line then push(line) end + -- A word longer than the line is BROKEN ACROSS lines rather than cut. + -- It is always a path, and a path is the one thing here that has to be + -- readable in full -- an absolute Android save directory runs to + -- ninety-odd characters with no spaces in it at all, so truncating at + -- twenty told the player almost nothing. + while #word > cols do + push(word:sub(1, cols)) + word = word:sub(cols + 1) + end + line = word + end + if #lines >= limit then break end + end + if line then push(line) end + return lines +end + +-- ------- dex number -> the engine's own species key +-- +-- Built once, from the loaded data rather than from a list of names carried +-- here: a list would be a second place for the same 151 facts to live, and +-- would go stale against a mod that renames one. +local dexNames = nil + +local function speciesName(dex) + if not dex then return nil end + if not dexNames then + dexNames = {} + local ok, data = pcall(function() + return require("src.core.Game").data + end) + if ok and data and data.pokemon then + for key, def in pairs(data.pokemon) do + if type(def) == "table" and def.dex then dexNames[def.dex] = key end + end + end + end + return dexNames[dex] +end + +-- `adopt` means the caller has ALREADY started the build, or already decided +-- it cannot start -- which is the imported path (StadiumRomPick opens the +-- picked file itself, because love.filesystem cannot read an absolute path). +-- Without it this screen would call begin() on the way in and throw away the +-- job it was pushed to display, or overwrite the failure it was pushed to +-- explain with a fresh "no ROM in baseroms" -- which would be true, and would +-- have nothing to do with what just went wrong. +function StadiumScreen.new(game, adopt) + return setmetatable({ game = game, hold = 0, started = adopt and true or false, + adopted = adopt and true or false }, StadiumScreen) +end + +-- ------- the same plate, saying something instead of doing something +-- +-- A NOTE: title, a wrapped body, and a key to dismiss it. It exists because +-- the one piece of information a player on a platform with no file dialog +-- actually needs -- the absolute path of the folder to put the cartridge in +-- -- is long, machine-specific, and was only ever written to the console, +-- which nobody on a phone can read. +-- +-- Same state shape and the same plate as the build screen, so there is one +-- look and one set of stack manners rather than two. +function StadiumScreen.newNote(game, title, lead, body) + return setmetatable({ game = game, + note = { title = title, lead = lead, body = body } }, + StadiumScreen) +end + +-- Opaque: the loading screen owns the frame, so the map underneath is not +-- drawn and not paying for a render it cannot be seen through. +StadiumScreen.isOpaque = true + +function StadiumScreen:enter() + if self.note or self.adopted then return end + local ok, err = StadiumInstall.begin() + self.started = ok and true or false + if not ok then + StadiumInstall.status.state = "failed" + StadiumInstall.status.error = err + end +end + +-- The buttons that dismiss a note. Every face button and START, because the +-- prompt says ANY and a player who has to hunt for the right one on a phone +-- has been lied to. +local DISMISS = { "a", "b", "start", "select" } + +function StadiumScreen:update() + -- ------- a note is dismissed by a BUTTON, not by a key + -- + -- `onKeyPressed` is the keyboard, and a phone has none: the touch overlay + -- feeds the engine's Input as virtual buttons (Input.overlayPressed), so a + -- state that only listens for keys cannot be closed by touch at all. That + -- stranded a player on this screen with no way off it -- the one screen in + -- the mod whose entire job is to tell somebody something and then get out + -- of the way. + -- + -- Polled here rather than handled as an event because `wasPressed` is the + -- edge test the engine's own battle screens use, and it is fed by the + -- keyboard, the gamepad AND the overlay through one path. + if self.note then + local input = self.game and self.game.input + if input and input.wasPressed then + for _, btn in ipairs(DISMISS) do + if input:wasPressed(btn) then + if self.game.stack and self.game.stack:top() == self then + self.game.stack:pop() + end + return + end + end + end + return + end + local status = StadiumInstall.status + if status.state == "building" then + if not StadiumInstall.step() then + -- fell out of building: either finished or failed, both of which hold + -- for a moment so the player sees which + self.hold = 0 + end + return + end + self.hold = self.hold + 1 / 60 + -- a failure stays up longer, because it is the one the player has to read + local wait = StadiumScreen.HOLD + if status.state == "failed" then + wait = StadiumScreen.HOLD * 4 + elseif status.wrongVersion then + -- a warning nobody can read is not a warning + wait = StadiumScreen.HOLD * 3 + end + if self.hold >= wait then + if self.game and self.game.stack and self.game.stack:top() == self then + self.game.stack:pop() + end + end +end + +-- Let the player out of a build that has gone wrong, or that they would +-- rather not wait for. Cancelling leaves the packs unbuilt, so the STADIUM +-- rungs stay off the row until the next boot offers again -- which is +-- honest, and better than a half-built set. +local function pop(self) + if self.game and self.game.stack and self.game.stack:top() == self then + self.game.stack:pop() + end +end + +function StadiumScreen:onKeyPressed(key) + -- A note takes any key too. This is the KEYBOARD path and it is not the + -- one that matters on a phone -- see update, which polls the engine's + -- Input so the touch overlay's virtual buttons work as well. + if self.note then pop(self) return true end + if key == "escape" or key == "x" or key == "backspace" then + StadiumInstall.cancel() + if self.game and self.game.stack and self.game.stack:top() == self then + self.game.stack:pop() + end + return true + end + return false +end + +function StadiumScreen:draw() + local status = StadiumInstall.status + love.graphics.setColor(0.93, 0.94, 0.90, 1) + love.graphics.rectangle("fill", 0, 0, W, H) + + if self.note then + centred(self.note.title, 12) + -- The sentence is kept SHORT so the path can have the rest of the plate + -- at full size. Shrinking the path was tried first and does not survive + -- the font: these are 1-bit 8x8 bitmaps, so a fractional downscale drops + -- whole pixel rows out of every glyph and the last line came out as + -- mush. Nine rows of twenty characters is 180, which is longer than any + -- real save path, so nothing has to be shrunk to fit. + local lead = wrapped(self.note.lead or "", 2) + for i, line in ipairs(lead) do centred(line, 30 + (i - 1) * 10) end + local y = 30 + #lead * 10 + 6 + for i, line in ipairs(wrapped(self.note.body or "", 9)) do + centred(line, y + (i - 1) * 9) + end + centred("PRESS ANY KEY", 130) + love.graphics.setColor(1, 1, 1, 1) + return + end + + -- One line, and it is the whole heading: eighteen glyphs at the font's + -- fixed eight pixels is 144 of the frame's 160. + centred("STADIUM EXTRACTION", 34) + + if status.state == "failed" then + centred("COULD NOT BUILD", 68) + local lines = wrapped(status.error or "unknown", 2) + for i, line in ipairs(lines) do centred(line, 82 + (i - 1) * 10) end + centred("STADIUM IS OFF", 110) + love.graphics.setColor(1, 1, 1, 1) + return + end + + local done = status.done or 0 + local total = status.total or StadiumInstall.COUNT + local frac = (total > 0) and (done / total) or 1 + if status.state == "done" then frac = 1 end + if frac < 0 then frac = 0 elseif frac > 1 then frac = 1 end + + -- The bar: a dark frame, an EMPTY interior the same colour as the plate, + -- and a dark fill growing left to right. + -- + -- The track has to be the plate's own white rather than a light grey. This + -- draws inside the Game Boy frame, so the colorization pass quantises + -- everything here into the four GB shades and paints them -- and a grey + -- track lands one shade down, which comes out as a bar that is GREEN where + -- the work is still to do and dark where it is done. The eye reads colour + -- as the filled part and gets the progress exactly backwards. + local bx, by, bw, bh = 24, 68, W - 48, 9 + love.graphics.setColor(0.06, 0.05, 0.09, 1) + love.graphics.rectangle("fill", bx - 1, by - 1, bw + 2, bh + 2) + love.graphics.setColor(0.93, 0.94, 0.90, 1) + love.graphics.rectangle("fill", bx, by, bw, bh) + love.graphics.setColor(0.06, 0.05, 0.09, 1) + love.graphics.rectangle("fill", bx, by, math.floor(bw * frac + 0.5), bh) + + if status.state == "done" then + centred("READY", 86) + -- and say so if it was built from something other than the revision every + -- offset in the reader was measured against: it may look fine, it may be + -- subtly wrong, and the player is the only one who can swap the file + if status.wrongVersion then + centred("NOT US 1.0 --", 104) + centred("MODELS MAY BE WRONG", 114) + end + else + local name = speciesName(status.species) + centred(("%d/%d"):format(done, total), 86) + if name then centred(name, 98) end + end + love.graphics.setColor(1, 1, 1, 1) +end + +-- ------- when this comes up +-- +-- The first frame the player is actually IN the world, rather than at boot. +-- Two reasons. The engine has its own launcher and ROM importer before the +-- game starts, and pushing over those would be fighting them for the screen. +-- And `Game.data` has to be loaded for the species names above to resolve. +-- +-- Asked once. If the player cancels, or there is no ROM, this does not come +-- back until the next run -- a loading screen that reappears every time you +-- step outside would be worse than no stadium models. +local asked = false + +function StadiumScreen.maybePush() + if asked then return false end + local ok, Game = pcall(require, "src.core.Game") + if not (ok and Game and Game.stack and Game.overworld) then return false end + if Game.stack:top() ~= Game.overworld then return false end + asked = true + if not StadiumInstall.pending() then + -- Say where to put a cartridge, ONCE, and only when there is nothing to + -- build from and nothing already built. The two STADIUM rungs are simply + -- absent in that case (ModSetting.setGate), which is the right thing for + -- a row to do and tells the player nothing about why -- and the answer + -- they need is an absolute path that depends on how the game was + -- installed, so it cannot be written into the options help text. + if not StadiumInstall.available() then + -- The IMPORT row is the answer wherever a file dialog can be opened, + -- and it is the better one: no folder to create, no path to get right, + -- no restart. The folder is still said, once, for the platforms with no + -- dialog (Android, a handheld Linux with neither zenity nor kdialog) + -- and for anyone who would rather drop a file than click through one. + -- The STADIUM ROM row is on the OPTIONS menu on every platform now, so + -- point at it rather than reciting a path here: where a file dialog can + -- be opened it opens one, and where it cannot it shows this same folder + -- on screen -- which is the part a phone could not otherwise find out. + local okPick, pick = pcall(V.require, "StadiumRomPick") + local label = (okPick and pick and pick.LABEL) or "STADIUM ROM" + local how = (okPick and pick and pick.canDialog()) + and "opens a file picker" or "says where to put one" + V.mod.log:info("stadium: no Pokemon Stadium (US) 1.0 ROM found, so the " + .. "STADIUM battle rungs are off. OPTIONS -> %s %s; the " + .. "folder is %s", label, how, StadiumInstall.romHint()) + end + return false + end + Game.stack:push(StadiumScreen.new(Game)) + return true +end + +-- named for the suite, which drives the screen without a boot +function StadiumScreen._reset() + asked = false +end + +return StadiumScreen diff --git a/lib/StadiumStage.lua b/lib/StadiumStage.lua new file mode 100644 index 0000000..7336ff9 --- /dev/null +++ b/lib/StadiumStage.lua @@ -0,0 +1,340 @@ +-- The B rungs: the two discs the fight is staged on. +-- +-- Where an A rung puts the fight on the MAP -- real ground, whatever the +-- route happens to look like -- a B rung puts it on two platforms against +-- the sky and draws no map at all. +-- +-- ------- one stage, two rungs +-- +-- The discs do not know what is standing on them. 2D-3D B stands the Game +-- Boy's own battle pics there and STADIUM B stands the Pokemon Stadium +-- models, and this file is identical for both: it draws two platforms at two +-- cells and sizes each to whatever footprint it is given. That is why the +-- flat disc rung cost a value in the 3D-BTL ladder and nothing else. +-- +-- ------- why this is a rung and not a fix +-- +-- Staging on the map is the better picture when the map cooperates, and it +-- often does not. Half of Kanto's interiors are furniture; a cave floor can +-- be nothing but two-cell corridors; some maps have nowhere a fight can be +-- SEEN from a low camera and are declined outright (see BattleArena), which +-- drops the player back to the flat battle screen with no warning. And even +-- where a spot exists, the ground behind the foe is a hedge or a shop counter +-- rather than anything a battle wants behind it. +-- +-- Discs have none of those problems, because the stage is CARRIED rather than +-- found: it works on every map, in every building, at every step, and the +-- framing is the same every time. What it gives up is the thing STADIUM A is +-- for -- fighting somewhere real. +-- +-- ------- what stays +-- +-- The sky, and the light. A battle outdoors is under the hour's own sky, with +-- its bands and its sun or moon (Voxel3D.beginScene paints it when handed a +-- dressed one); a battle in a cave or a room is under that place's own void +-- and its own neutral light, exactly as the map itself would be. So the mode +-- is abstracted from the GROUND, not from the world -- walk into a cave at +-- midnight and the fight looks like a cave at midnight. +-- +-- And the framing. The camera, the pins, the HUDs, the text box, the move +-- animations and the depth of field are all identical, because every one of +-- them is hung off the arena's CELLS rather than off what is under them. That +-- is the same reason STADIUM A could be an option on the mode rather than a +-- second mode, and it is why this file is a few hundred lines and not a few +-- thousand. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local Mat4 = V.require("Mat4") +local Voxel3D = V.require("Voxel3D") + +local StadiumStage = {} + +local floor = math.floor +local sin, cos = math.sin, math.cos +local pi = math.pi + +-- ------- the shape of a disc +-- +-- Radius in world pixels, where a map cell is 16 and the two mons stand three +-- cells apart. +-- +-- A PLATFORM FOLLOWS WHAT STANDS ON IT rather than being one fixed size, +-- because the set's footprints run nearly tenfold: a Caterpie is under four +-- world pixels across and Moltres, wings out, is twenty-six. One radius for +-- both is either a dinner plate under the caterpillar or a doily under the +-- bird. +-- +-- RADIUS is the floor, and it is the number most species land on -- it is a +-- little over the map cell a Pokemon is sized to cover, which is the +-- proportion the Game Boy's own battle platforms have. PAD is the margin +-- around a mon that needs more than that, and MAX_RADIUS stops Moltres from +-- being handed something the frame cannot hold. +-- +-- These are the FULL radius, out to where the fade has finished; the solid +-- centre a Pokemon actually stands on is SOLID of it. PAD is sized so that a +-- mon's own footprint fits inside that centre rather than out over the +-- stipple -- 1.8 x 0.70 is a little over 1.25, so a big Pokemon still has +-- solid ground under its edges. +StadiumStage.RADIUS = 18 +StadiumStage.MAX_RADIUS = 34 +StadiumStage.PAD = 1.8 + +-- The platform for a mon of this footprint. `r` may be nil -- nothing is +-- standing there yet, which is every frame of the send-out before the model +-- appears, and it is also the whole of the flat 2D-3D B rung, where a +-- Pokemon is a battle pic sized to cover exactly one map cell and RADIUS is +-- already a little over that. Either way the platform is the plain one, and +-- it has to be there BEFORE the Pokemon lands on it. +function StadiumStage.radiusFor(r) + local want = (r or 0) * StadiumStage.PAD + if want < StadiumStage.RADIUS then return StadiumStage.RADIUS end + if want > StadiumStage.MAX_RADIUS then return StadiumStage.MAX_RADIUS end + return want +end + +-- Per-vertex shading, in the same terms StadiumRig lights the models with, so +-- a disc and the Pokemon standing on it agree about where the sun is. Fitted +-- to Voxel3D.FACE_SHADE's six values: the constant is the average, and each +-- axis term is half the spread between that axis's two faces. +local SHADE_BASE = 0.7725 +local SHADE_X = 0.06 +local SHADE_Y = 0.225 +local SHADE_Z = 0.11 + +local function shadeFor(nx, ny, nz) + local s = SHADE_BASE + nx * SHADE_X + ny * SHADE_Y + nz * SHADE_Z + if s < 0.30 then return 0.30 end + if s > 1.00 then return 1.00 end + return s +end + +-- ------- the texture +-- +-- The platform is a FLAT painted disc that fades out at its rim -- no rim +-- wall, no thickness, the whole thing carried in one texture on one quad +-- lying on the ground plane. That is what the Game Boy's own battle +-- platforms are, and it is what keeps the stage from competing with the +-- Pokemon standing on it. +-- +-- ------- why the fade is DITHERED +-- +-- The scene shader discards any texel under half alpha outright (it has to: +-- that is what keeps a sprite's transparent corners out of the depth buffer). +-- So a smooth alpha ramp does not fade -- it comes out as a hard circle cut +-- at wherever the ramp crosses 0.5, which is the one thing this must not be. +-- +-- The fade is therefore an ORDERED DITHER baked into the texture's alpha: +-- every texel is fully on or fully off, and the proportion that are on falls +-- away toward the rim. It is the same trick the sky already uses for its +-- bands (Sky.DITHER), it needs no shader change and so risks nothing in any +-- other pass, and on a mode built out of visible texels it reads as intended +-- rather than as a limitation. +-- +-- The COLOUR is deliberately neutral. Everything the shader does to it after +-- this is the environment's: Voxel3D.tint carries the hour outdoors and the +-- room's own flat light indoors, and the shadow pass darkens whatever the +-- Pokemon standing on it occludes. So one texture is a sunlit platform, a +-- dusk platform and a cave platform, without a variant for each. +StadiumStage.TEX = 128 + +-- Where the solid centre ends, as a fraction of the disc's radius. Inside +-- this everything is opaque; from here to the rim the dither thins out. +StadiumStage.SOLID = 0.76 + +local TOP = { 0.74, 0.71, 0.63 } +local TOP_ALT = { 0.67, 0.64, 0.57 } + +local texture = nil + +-- A small deterministic scatter, for the surface itself. Not a random one: an +-- authored constant that happens to look unpatterned is worth more here than +-- a seed, because it can never change under a different Lua. +-- +-- Quantised into blocks, so the surface reads as TEXELS rather than as noise. +-- Per-pixel it came out as a fine mottle that fought the dithered rim for +-- attention -- and the rim is the thing worth looking at. At this size the +-- grain is roughly the size of the voxels everywhere else in the mode. +StadiumStage.GRAIN = 4 + +local function grain(x, y) + local bx = (x - x % StadiumStage.GRAIN) / StadiumStage.GRAIN + local by = (y - y % StadiumStage.GRAIN) / StadiumStage.GRAIN + local v = (bx * 37 + by * 71 + ((bx * by) % 13) * 17) % 100 + return v < 34 +end + +-- The 8x8 ordered (Bayer) matrix, as thresholds in 0..63. Ordered rather than +-- random because a random dither crawls: this pattern is fixed in the +-- texture, so the fade holds still while the camera drifts across it. +local BAYER = { + { 0, 32, 8, 40, 2, 34, 10, 42 }, + { 48, 16, 56, 24, 50, 18, 58, 26 }, + { 12, 44, 4, 36, 14, 46, 6, 38 }, + { 60, 28, 52, 20, 62, 30, 54, 22 }, + { 3, 35, 11, 43, 1, 33, 9, 41 }, + { 51, 19, 59, 27, 49, 17, 57, 25 }, + { 15, 47, 7, 39, 13, 45, 5, 37 }, + { 63, 31, 55, 23, 61, 29, 53, 21 }, +} + +function StadiumStage.texture() + if texture ~= nil then return texture or nil end + local ok, img = pcall(function() + local n = StadiumStage.TEX + local data = love.image.newImageData(n, n) + local half = (n - 1) / 2 + local solid = StadiumStage.SOLID + for y = 0, n - 1 do + local dy = (y - half) / half + for x = 0, n - 1 do + local dx = (x - half) / half + local d = (dx * dx + dy * dy) ^ 0.5 + -- how much of this texel's neighbourhood should survive: everything + -- inside the solid core, nothing past the rim, and a smooth ramp + -- between the two that the dither turns into a stipple + local cover + if d <= solid then + cover = 1.0 + elseif d >= 1.0 then + cover = 0.0 + else + local t = (d - solid) / (1.0 - solid) + cover = 1.0 - t * t * (3 - 2 * t) -- smoothstep, falling + end + local threshold = (BAYER[y % 8 + 1][x % 8 + 1] + 0.5) / 64 + local a = (cover > threshold) and 1 or 0 + local c = grain(x, y) and TOP_ALT or TOP + data:setPixel(x, y, c[1], c[2], c[3], a) + end + end + local image = love.graphics.newImage(data) + -- nearest, like every other texture in this mode: the grain and the + -- stipple are both meant to read as texels. Clamped rather than + -- repeating now that one texture covers the whole disc. + image:setFilter("nearest", "nearest") + image:setWrap("clampzero", "clampzero") + return image + end) + texture = (ok and img) or false + return texture or nil +end + +-- ------- the mesh +-- +-- One quad, lying flat on the ground plane, spanning -1..1 in x and z with +-- the whole texture stretched across it. The DISC is the texture's business, +-- not the geometry's -- everything outside the painted circle is alpha the +-- shader discards -- which is what "a flat texture that fades out at the +-- edges" means and what makes this four vertices rather than a hundred and +-- fifty. +-- +-- Shaded as a face pointing straight up, because it is one. + +local mesh = nil + +local function build() + local s = shadeFor(0, 1, 0) + local verts = { + { -1, 0, -1, 0, 0, s }, + { 1, 0, -1, 1, 0, s }, + { 1, 0, 1, 1, 1, s }, + { -1, 0, 1, 0, 1, s }, + } + return Voxel3D.newMesh(verts, { 1, 2, 3, 1, 3, 4 }) +end + +function StadiumStage.mesh() + if mesh == nil then mesh = build() or false end + return mesh or nil +end + +function StadiumStage.invalidate() + if texture and texture.release then pcall(texture.release, texture) end + if mesh and mesh.release then pcall(mesh.release, mesh) end + texture, mesh = nil, nil +end + +-- How far under the ground plane the disc actually sits. A hair, and only so +-- that a flat-footed Pokemon's sole -- which is AT the ground plane -- is not +-- coplanar with it and left to the depth buffer's mercy. +StadiumStage.SINK = 0.06 + +-- Where one disc sits: centred on a cell, at the ground plane, so a Pokemon +-- placed at that same height stands ON it rather than in it. +function StadiumStage.matrix(x, groundY, z, radius) + radius = radius or StadiumStage.RADIUS + return Mat4.mul(Mat4.translate(x, groundY - StadiumStage.SINK, z), + Mat4.scale(radius, 1, radius)) +end + +-- The two platforms this frame, as (side, matrix) -- shared by the camera's +-- pass and the sun's, so the two can never disagree about where they are. +local function each(arena, groundY, fn) + local ok, Stadium = pcall(V.require, "Stadium") + for _, side in ipairs({ "enemy", "player" }) do + local cell = arena[side] + if cell then + local footprint = ok and Stadium and Stadium.footprint(side) or nil + fn(StadiumStage.matrix(cell[1], groundY, cell[2], + StadiumStage.radiusFor(footprint))) + end + end +end + +-- ------- the synthetic arena +-- +-- A B rung does not search the map, because it does not stand on it. The +-- arena is the same WIDE shape every other staged fight uses -- so the two +-- cells are three apart down the middle and BattleCam frames them exactly as +-- it always has -- just placed at a fixed spot rather than a found one. +-- +-- Away from the origin on purpose. The coordinates run through the camera +-- solve, the sun's frustum fit and the projection to Game Boy pixels, and +-- putting a stage at (0, 0) is the kind of thing that hides a sign error for +-- months. +StadiumStage.ORIGIN = { 16, 16 } + +function StadiumStage.arena(map) + local BattleArena = V.require("BattleArena") + local arena = BattleArena.at(StadiumStage.ORIGIN[1], StadiumStage.ORIGIN[2], + "wide") + if not arena then return nil end + -- the map is carried for its SKY and its palette only -- what kind of place + -- the fight is happening in -- never for its geometry + arena.map = map + arena.discs = true + return arena +end + +-- ------- the draws + +-- The discs, in the main pass. No wireframe: everything else in this frame is +-- built a unit per voxel and wears the seams that fall out of that, and a +-- disc is a turned solid with no grid to draw. +function StadiumStage.draw(arena, groundY) + if not (arena and arena.discs) then return end + local m = StadiumStage.mesh() + local tex = StadiumStage.texture() + if not (m and tex) then return end + Voxel3D.seams(false) + Voxel3D.glass(false) + each(arena, groundY, function(matrix) Voxel3D.draw(m, tex, matrix) end) + Voxel3D.glass(true) + Voxel3D.seams(true) +end + +-- And into the sun, so the two Pokemon put real shadows on the platforms they +-- are standing on. Without this the shadow map is empty where the discs are +-- and a mon casts onto nothing at all -- which, with no ground behind it +-- either, reads as the pair floating. +function StadiumStage.cast(shadowMap, arena, groundY) + if not (arena and arena.discs and shadowMap) then return end + local m = StadiumStage.mesh() + local tex = StadiumStage.texture() + if not (m and tex) then return end + each(arena, groundY, function(matrix) shadowMap.draw(m, tex, matrix) end) +end + +return StadiumStage diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index 5bee511..a15f265 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -1159,6 +1159,30 @@ function Voxel3D.seams(on) on and VoxelGrid.DARK or 0) end +-- ADDITIVE for the length of a draw, or nil to put the pass back the way +-- it was found. +-- +-- Exactly one thing asks for this: the flame and gas primitives on a +-- STADIUM battle model (Charmander's tail, Weezing's cloud -- see +-- StadiumRig). Those are light, not surface: they are drawn over a body +-- that is already in the depth buffer and they must ADD to it rather than +-- replace it, or the flame comes out as an opaque orange sticker. +-- +-- Depth WRITES go off with the blend, and for the usual reason -- a +-- translucent thing that wrote depth would punch whatever comes after it +-- out of the frame. The test stays on, so a flame behind a tree is still +-- behind the tree. +function Voxel3D.blend(mode) + if not active then return end + if mode == "add" then + pcall(love.graphics.setBlendMode, "add", "alphamultiply") + pcall(love.graphics.setDepthMode, "lequal", false) + else + pcall(love.graphics.setBlendMode, "alpha", "alphamultiply") + pcall(love.graphics.setDepthMode, "lequal", true) + end +end + -- Whether what is drawn next may consult the glass mask. false for the -- length of a sprite-sheet pass, true to put it back. -- diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index 0e76ef2..dd4a146 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -865,6 +865,16 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, ShadowMap.draw(BattleBillboard.mesh(), card.tex, ShadowMap.snug(card.model)) end ShadowMap.sprites(false) + -- and the STADIUM models, outside the sprite flag and un-snugged, for + -- the reasons the flat battle pass gives (BattleScene.castShadows): + -- these are geometry, not cut-outs + pcall(function() + local stageArena, stageY = V.require("OverworldBattle").stage() + if stageArena and stageArena.discs then + V.require("StadiumStage").cast(ShadowMap, stageArena, stageY or 0) + end + V.require("Stadium").cast(ShadowMap) + end) ShadowMap.finish(sig) end @@ -1072,6 +1082,24 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model, BattleBillboard.PULL) end + -- and, on the STADIUM rungs, the models -- the same skinned meshes the + -- flat pass and the sun already used this frame, drawn again through + -- THIS eye. Unlike the cards there is nothing per-eye about them: a + -- model faces its opponent, not the viewer, so both eyes see the same + -- pose from their own seats, which is what makes it read as solid. + -- + -- On a disc rung the platforms come with them. In a headset the world is + -- still drawn -- the player is standing IN it, which is the whole point + -- of the headset, so the rung's "no map" does not apply here -- and the + -- discs then read as a stage set down on the ground, which is what they + -- are. + pcall(function() + local stageArena, stageY = V.require("OverworldBattle").stage() + if stageArena and stageArena.discs then + V.require("StadiumStage").draw(stageArena, stageY or 0) + end + V.require("Stadium").draw(BattleBillboard.PULL) + end) if battleTex.flash then Voxel3D.flatten(nil) end -- and the MOVE ANIMATIONS, standing on the same arena: the -- engine's own effects layer on the plane through both cells diff --git a/main.lua b/main.lua index cc062c3..4714165 100644 --- a/main.lua +++ b/main.lua @@ -200,6 +200,19 @@ mod.content.render_pipelines:register("voxel", { -- and the whole battle. Ahead of the active() gate below, because a 3D -- battle does not require the free-roam mode to be switched on. OverworldBattle.update(dt) + -- The one-time build of the Pokemon Stadium battle models out of the + -- player's own ROM, if there is one to build from and it has not been + -- done (see StadiumInstall). Rides this hook for the same reason the + -- battle does -- it is the tick that runs whatever is on the stack -- and + -- asks exactly once, on the first frame the player is actually in the + -- world, so it is never fighting the engine's own launcher for the + -- screen. + pcall(function() V.require("StadiumScreen").maybePush() end) + -- and a ROM the system file picker dropped in the save directory while + -- we were not the top activity (Android; see StadiumRomPick.poll) + pcall(function() + V.require("StadiumRomPick").poll(require("src.core.Game")) + end) -- The horde, on the same always-running tick and for the same reason: -- it owns no pass of the frame, it is a MODE over the overworld, and -- it has to keep thinking while a warp's wipe covers the screen (the @@ -421,8 +434,18 @@ local SETTINGS = { -- and forbids back sprites (backPinned answers false), so both rows -- decide nothing there and a dead switch on the menu reads as broken. { OverworldBattle.setting, - "Fight on the map: the battle draws over the nearest clear ground, " - .. "shot over the shoulder with a slow parallax drift.", + "Fight in three dimensions, shot over the shoulder with a slow parallax " + .. "drift. 2D-3D stands the game's own battle pics up as cards; STADIUM " + .. "replaces them with the Pokemon Stadium battle models, animated, " + .. "playing the animation the move being used actually calls for. A " + .. "stages the fight on the MAP -- the nearest clear ground, in that " + .. "place's own weather and light; B stands it on two discs against the " + .. "sky instead, which works everywhere, including the caves and shop " + .. "floors that have nowhere to stage a fight. The STADIUM rungs only " + .. "appear once the models have been built, and building them needs a " + .. "Pokemon Stadium (US) 1.0 ROM of your own -- import it from the " + .. "STADIUM ROM row, or drop it in the baseroms folder and restart. No " + .. "other version works: the reader is keyed to that one cartridge.", when = function() return not VR.enabled() end, full = true }, -- Only offered while a fight can actually be staged on the map: with 3D-BTL -- off the engine draws the classic screen, which is this row's ON already, @@ -496,7 +519,7 @@ mod.options:define(schema) -- 5 V-GRID toggle the wireframe (new) -- 6 T-SHIFT cycle the blur ladder (was 9) -- 7 V-CURVE cycle the horizon bend (new) --- 8 3D-BTL toggle overworld battles (new) +-- 8 3D-BTL cycle overworld battles (new) -- 9 WATER cycle the water reflections (new; 9 was T-SHIFT's old key) -- -- Only 6 arrives by the documented route. Game:keypressed answers the @@ -779,6 +802,19 @@ mod.hooks:wrap("ui.options.rows", function(next, game, rows) and (not entry.when or entry.when()) if offered then extra[#extra + 1] = entry[1]:row() end end + -- and the ROM import, which is an ACTION and not a setting: there is no + -- rung to store, nothing for the mod manager's page to persist and nothing + -- to restore on the next boot, so it is appended here rather than living in + -- SETTINGS. nil on a platform with no file dialog, which takes it off the + -- menu rather than offering a button that cannot do anything. + -- On EVERY platform. Where there is no file dialog it says WHERE? and + -- shows the folder to put the cartridge in, which is the one thing a + -- player on a phone could not otherwise find out -- the row used to vanish + -- there, which reads as the feature being missing rather than manual. + local okPick, importRow = pcall(function() + return V.require("StadiumRomPick").row() + end) + if okPick and importRow then extra[#extra + 1] = importRow end return insertGrouped(out, extra) end) diff --git a/manifest.json b/manifest.json index bddd212..d78f4c8 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "Dramatic Shape Voxel Mod", - "version": "1.5.5", + "version": "1.6.0", "api": 2, "entry": "main.lua", "profile": "content", @@ -15,6 +15,6 @@ "engine_internals" ], "affects_link": false, - "description": "A full 3D diorama overworld: extruded terrain, depth-buffered occlusion, voxel characters and a tilt-shift miniature pass -- and battles fought on the map itself, shot over the shoulder at the nearest clear ground with a slow parallax drift and a depth-of-field pass. Water reflects the sky, the sun, the moon and -- through a screen-space ray march -- the shoreline standing behind it. Registers two render pipelines and claims hotkeys 3, 5, 6, 7, 8 and 9 -- 3 and 5 displace the engine's TILT and GBC FX keys, both still reachable on the OPTIONS menu. Presentational only: it changes what a battle is drawn over, never where anybody stands.", + "description": "Draws the overworld as a 3D diorama.", "github": "DramaticShape/DramaticShapeVoxelMod" } diff --git a/mod.card b/mod.card index d3ac85c..93e0c27 100644 --- a/mod.card +++ b/mod.card @@ -10,7 +10,9 @@ return { changed = { "with VOXEL on, the overworld draws as 3D geometry instead of flat tiles", "occlusion comes from a depth buffer rather than a y-sort, so buildings really hide what is behind them", - "with 3D-BTL on, a battle draws over the map's nearest clear ground instead of over a white field", + "with 3D-BTL on 2D-3D A, a battle draws over the map's nearest clear ground instead of over a white field", + "with 3D-BTL on a B rung, the fight is staged on two discs against the sky with no map drawn at all, which works on every map including the caves and shop floors that have nowhere to stage a fight", + "with 3D-BTL on STADIUM A or STADIUM B, the same fight is staged with the Pokemon Stadium battle models in place of the flat pics -- 148 of the 151 species, skinned and animated, playing the animation the move being used actually calls for; Exeggutor, Tangela and Magmar have corrupt animation data at source and keep their battle sprites", "the battle's text box and menu are frosted glass over that ground rather than an opaque white slab, on the same panels the HUDs sit on", "the map's NPCs are culled for the length of a battle, so the wipe plays over an empty map", "a battle's letterbox voids go black rather than white, because the battle canvas is no longer white", @@ -31,7 +33,8 @@ return { "V-GRID on hotkey 5 and V-CURVE on hotkey 7", "Q and E zoom whichever camera is in front of you -- the third-person boom, a staged battle's lens, or the engine's own survey zoom on an orbit rung -- alongside the mouse wheel, a two-finger pinch, and the pad's left and right stick clicks (out and in). 1ST claims none of them: the eye is in the player's head and there is no distance to change", "WATER on hotkey 9 (FULL / SKY / OFF, FULL by default): the water surface becomes a field of pixel-tall voxel columns rising and falling as waves, reflecting the sky, the sun, the moon and the cast standing beside it -- and, on FULL, the shoreline, trees and buildings behind it, by a screen-space ray march", - "3D-BTL on hotkey 8 (ON / OFF, on by default), battles fought on the world map", + "3D-BTL on hotkey 8 (2D-3D A / 2D-3D B / STADIUM A / STADIUM B / OFF, 2D-3D A by default), battles fought in 3D -- 2D-3D stands the game's own pics up as cards and STADIUM replaces them with the Pokemon Stadium battle models, while A stages the fight on the map and B on two carried discs against the sky. Only the STADIUM rungs need a ROM, and they are on the row once those models have been built (see below); 2D-3D B is generated in Lua and needs nothing", + "the STADIUM animations are driven from the fight: a move plays the animation that species' own battle table names for it (so DIG really does put Diglett into the ground), fainting plays the faint and holds there, and a send-out grows the Pokemon out of the ball and plays the entrance. Damage plays nothing -- the set has no reaction animation in it, and the engine's own flash, blink and HP drain already say so. The eyes blink and go dizzy, and Charmander's tail flame and Weezing's gas are drawn over the body", "BACK SPRITES options row (OFF / ON, off by default), which keeps your own Pokemon on the battle menu in its classic slot while the foe stands out on the map", "VR options row (OFF / ON, off by default): PCVR through OpenXR on Windows -- the diorama as a head-tracked tabletop model presented at the rung's own angle and framing on the orbit rungs, life-size first person on 1ST, a staged battle snapping the headset (through a fade to black) into the flat game's own over-the-shoulder seat at life scale, a voxel Pokedex flush along the left controller in first person and in battles (menus, dialogs and the 2D battle screen on its screen; the diorama does without it), the sky and its sun and moon anchored in space (bands, GBC dither and twilight glow alike -- nothing in the sky reacts to the head), the floating panel wearing the GB frame near-square rather than the whole monitor-wide window (scaled into the headset, so the picture and its ratio are identical at every window size, fullscreen included), the window as mirror; needs a runtime (SteamVR/Oculus/WMR) and the mod on a real folder", "VR controllers (Touch/Index/WMR, rebindable in the runtime): left stick moves, A/B are A/B, either trigger is START, left stick click steps the VOXEL angle ladder exactly as the 3 key and SELECT do; in 1ST the right stick snap-turns 45 degrees a flick; in the diorama the right stick zooms and a squeezed grip drags the table's height; no controller button leaves VR -- that is the VR row's job", @@ -49,6 +52,9 @@ return { "WATER on FULL ray-marches the depth buffer per water pixel, so a map that is mostly sea costs real fill rate on a weak GPU -- SKY is the same look minus the ray march, and OFF is the flat water", "a screen-space reflection can only reflect what is in the frame: a tree just off the top edge is not in the water below it, and a ray that runs off the side fades into the sky rather than ending on a line", "a map with no 3x6 clearing falls back to a 1x4 one, and a map with neither draws the plain battle screen", + "the STADIUM rungs need the Pokemon Stadium battle models, and the mod ships none of them -- they are that game's data. Press STADIUM ROM on the OPTIONS menu to pick one with the system file dialog, or drop one in a baseroms/ folder beside the game. It must be Pokemon Stadium (US) 1.0 (md5 ed1378bc12115f71209a77844965ba50) -- every offset in the reader is keyed to that cartridge, and anything else is refused or builds wrong models; either way the 151 models are built out of it on a loading screen, in about ten seconds, into the save directory, and the ROM itself is not kept. Until then the two rungs are simply not on the row. Once built, a rung declines per POKEMON rather than per battle: a species with no pack, a standing substitute doll, and the trainer's own pic before the send-out each fall back to the flat card on that side alone, with the other side keeping its model", + "the STADIUM rungs size a Pokemon by its own model against the set's median, with the range compressed -- the authored heights span sixteenfold, from Caterpie to Gyarados, and a shared over-the-shoulder shot cannot hold that. The order and the feel of the differences survive; the literal ratios do not", + "three species -- Exeggutor, Tangela and Magmar -- have standby loops that are corrupt in the source extraction, and are held at their bind pose so they stand still rather than coming apart", "the arena is where the CAMERA goes -- nobody is moved, so a fight staged across the map is a shot of that ground, not a trip to it", "the battle backdrop renders at the GB's 160x144 to match the pics composited over it, so it is chunkier than the free-roam pass", "menus and cutscenes are unaffected -- outside a battle the mode only draws the free-roam overworld", @@ -71,6 +77,7 @@ return { }, credits = { { who = "pret/pokered", for_ = "the tile and sprite data the geometry is derived from" }, + { who = "pret/pokestadium", for_ = "the decompilation the STADIUM extractor was written against -- the bone matrix chain and where scale is applied (func_800143C0), the rotation basis (func_8000F730), the animation and texture-animation samplers (func_80016FBC / func_80017540), the battle context slots and the move-id constants. No code or data from it is included or redistributed here; see README.md" }, { who = "The Khronos Group", for_ = "the OpenXR loader shipped unmodified in assets/vr (Apache-2.0; full license text alongside the DLL)" }, }, compat = { engine = ">=0.1.37 <2.0.0", modApi = 2 }, diff --git a/model_extract/README.md b/model_extract/README.md new file mode 100644 index 0000000..922b626 --- /dev/null +++ b/model_extract/README.md @@ -0,0 +1,181 @@ +# Pokemon Stadium (US) — battle model export + +> **Built on [pret/pokestadium](https://github.com/pret/pokestadium).** This +> pipeline is original code, but it could not have been written without that +> project's decompilation: the bone matrix chain and the fact that scale is +> kept out of it (`func_800143C0`), the rotation basis (`func_8000F730`), the +> animation and texture-animation samplers (`func_80016FBC`, `func_80017540`), +> the battle context slots, and the move-id constants all came from reading +> it. **No code or data from that project is vendored here or required to run +> this** — see the mod's [README](../README.md#acknowledgements--pretpokestadium), +> and get anything you want to reuse from upstream under its own terms. +> +> No ROM data is committed either: everything below is generated from a +> cartridge you supply. + +All 151 battle Pokemon plus 64 other models from the same segment, in standard +formats. Regenerate straight from the ROM — stdlib only, no `make init`, no +splat, no crunch64: + +```bash +model_extract/pipeline/build.py +``` + +Put a US 1.0 ROM in [baseroms/](baseroms/) (`.z64`, `.n64` or `.v64`), or pass +`--rom=PATH`. See [pipeline/README.md](pipeline/README.md) for the module layout, +how the ROM is unpacked, and the generated-effects notes. + +``` +viewer.html browse everything in the browser — open it directly +manifest.json every model + what each of its animations is used for +moves.json all 165 moves + the animation each species plays for them +glb/025_pikachu.glb glTF 2.0 binary: mesh, skeleton, skin, animations, textures +glb/x152_model.glb non-Pokemon models from the same segment (props, trophies…) +textures/025_pikachu/ the same textures as loose PNGs, named _x.png +js/ viewer payloads, one per model, plus index.js and moves.js +``` + +`viewer.html` has a filterable picker for every model plus prev/next/random, a +**move picker** that jumps to whichever animation the current Pokemon plays for +that move, per-animation playback with a frame scrubber, an eye/texture-animation +selector, and texture/lighting/wireframe/skeleton toggles. It reads `js/`, not +`glb/`, because browsers block `fetch` of local files from `file://` — script +injection is what lets the page work when you just double-click it. + +Sizes: 73 MB `glb/`, 54 MB `js/`, 7.4 MB `textures/`. Two flags if you want less: +`--no-js` skips the viewer payloads and `viewer.html` (leaving the glTF export +alone), and `--manifest-only` rebuilds just `manifest.json`. + +`.glb` files are self-contained and open directly in Blender, Maya, Unity, Unreal, +three.js, Godot, Windows 3D Viewer, macOS Quick Look, and https://gltf-viewer.donmccurdy.com. +Source file `N.bin` holds species `N + 1`. + +## Conventions + +- Y up, +Z front, right-handed — glTF standard. +- Animations are authored at **30 fps**; keyframe times are `frame / 30`. +- Units are game units. Models are authored 10x and scaled down by the + `model_root` node, matching the geo layout's scale command. +- Textures are `CLAMP_TO_EDGE`, materials are `alphaMode: MASK` with cutoff 0.5 + (N64 RGBA5551 has one bit of alpha). `doubleSided` follows the display list's + cull mode. + +## Skeleton + +The game keeps bone scale *out* of the matrix chain (`func_800143C0` in +[src/12D80.c](../src/12D80.c)): scale accumulates in its own stack, a bone's local +translation is pre-multiplied by the parent's accumulated scale, and the +accumulated scale is applied to the rows of the finished world matrix only at +draw time. glTF node TRS instead propagates scale multiplicatively to children, +so a 1:1 node mapping would be wrong wherever a non-uniformly scaled bone has +descendants. + +Each game bone is therefore exported as two nodes: + +| node | role | +| --- | --- | +| `boneNN` | pivot: `translation = t * accScale(parent)`, `rotation = R`, scale 1 | +| `boneNN_scale` | leaf child holding `scale = accScale(bone)`, so it cannot propagate | + +The skin binds to the `boneNN_scale` nodes, whose world matrices then equal the +game's draw matrices exactly. Vertices are already in bone-local space and each is +rigidly bound to one bone, so all inverse bind matrices are identity. + +This was verified by parsing each exported `.glb` back and diffing every joint +matrix against a reference implementation of the game's own math, over the bind +pose and four sampled frames of every animation. Worst-case disagreement is +~1e-2 game units on models spanning 20–40 units, entirely from storing rotations +as spec-normalised `SHORT` quaternions. + +## Animation semantics + +Per-species battle data lives in `assets/us/70D3A0.bin`, 0xB90 bytes per species, +DMA'd into the battle system by `func_84302658` via the `D_80075BD0[species-1]` +pointer table. It is an array of 0x10-byte entries; byte 0 of each entry is an +index into that Pokemon's animation list and byte 1 indexes the auxiliary list: + +- **entries 0–164** — one per move, in the move ID order of + [oldnotes/stadium1/constants/move_constants.s](../oldnotes/stadium1/constants/move_constants.s). + Entry *n* gives the animation played when the Pokemon uses move *n + 1*. +- **entries 165+** — fixed battle contexts (idle, hit, faint, …). + +Every one of the 151 species' tables indexes only animations that exist in that +species' list, which is what confirms the layout. + +`manifest.json` reports, for each animation, the exact list of moves that trigger +it plus which context slots reference it. `moves.json` inverts that: every move, +and the animation each of the 151 species plays for it. + +One caveat when reading move data: the table is **dense**. Every species has a +row for every move, including moves it can never learn, and those unreachable +rows overwhelmingly point at the species' generic reaction animation — the same +one the `hit` slot uses. So "118 species play Thunderbolt" is an artifact, not a +fact about the game. `moves.json` marks each row with `differsFromDefault` and +gives a `speciesWithOwnAnimation` count per move; treat those as the signal. The `animationSlots` section carries an +`evidence` field per slot: + +- `code` — the battle code in `src/fragments/62` names the slot outright. +- `data` — inferred from what the referenced animation actually does, measured + across all 151 species. For example slot 167 is labelled `faint` because its + animation always ends far from the standing pose (the model drops to + 0.03–0.84x idle height, or leaves the frame entirely for fliers), while slot + 168's animation always ends at exactly idle height. + +`endBehavior` reports what the animation player does past the last frame +(`func_80016FBC`): every animation in the game wraps back to `loopStartFrame`, so +one-shots like `faint` are ended by the battle state machine switching animation, +not by the player clamping. glTF has no loop flag, so importers will loop clips by +default. + +The common layout, consistent across nearly every species: + +| animation | role | +| --- | --- | +| 0 | idle / standby loop (all 151 species) | +| 1 | second idle-length animation, rarely referenced by the context slots | +| 2 | hit / damage reaction (149–151 species across slots 166, 178–181) | +| 3 … n-3 | attack animations, selected per move | +| n-2 | faint (slots 167, 177) | +| n-1 | entrance / return-to-idle cycle (slots 168, 183) | + +## Texture animations (blinking, dizzy eyes) + +The second animation list in the model root is a *texture* animation, not a +skeletal one (`src/18140.c`). Geo command `0x23` carries a channel index at +offset `0x02`; when it is `>= 0`, `func_800176DC` replaces that material's +texture every frame from a per-frame stream of texture-table indices. + +Charmander's eyes are the clearest example — texture 2 is the open eye, 3 and 4 +are blink frames, and 5–7 are the dizzy swirl: + +| aux animation | frames | texture stream | +| --- | --- | --- | +| 0 | 10 | `2 2 3 3 4 4 4 3 3 2` — a blink | +| 2 | 122 | cycles `5 6 7` — confusion swirl | +| 4 | 18 | a slower blink | + +The viewer plays these. Each skeletal animation is paired with the texture +animation the battle table most often sets alongside it, and the `eyes` dropdown +overrides that. glTF 2.0 has no texture-swap animation channel, so this data +lives in `js/` and `moves.json` rather than the `.glb` — the `.glb` files carry +the first frame's texture on each material. + +## Known gaps + +- **Move effect visuals are not here.** Geo command `0x24` does not draw + anything: `func_80014CB8` just records an attachment point (an id plus a world + position) on the Pokemon, and the battle system spawns particles there. Ids + 1–14 are generic and used by nearly every species. So Charmander's tail flame, + beams, explosions and the like are drawn by the effect system in the battle + fragments and are not present in these model files — Charmander's texture set + contains eyes, claws, teeth and skin, and no flame. +- **The Poke Ball throw/open model was not found.** It is not in this segment, + no other `assets/us/**.bin` contains a model fragment, and scanning the ROM + ranges of fragments 62–64 for embedded model headers found none. What does + exist is Poke Ball *2D* artwork in fragment 29 + (`fragments/29/fragment29_unk_bin_*`, flagged in the splat yaml). The throw is + most likely built from raw display lists rather than a geo-layout model. +- Files 151–214 are exported as `x_model` with generic names. They are + props, trophies, minigame pieces and similar; only Surfing Pikachu (file 152, + the same 37-bone / 723-triangle rig as Pikachu) is named with confidence. They + carry no battle table, so their animations are left unnamed. diff --git a/model_extract/baseroms/.gitignore b/model_extract/baseroms/.gitignore new file mode 100644 index 0000000..0f17c87 --- /dev/null +++ b/model_extract/baseroms/.gitignore @@ -0,0 +1,5 @@ +baserom.z64 +*.z64 +*.n64 +*.v64 +*:Zone.Identifier diff --git a/model_extract/baseroms/README.md b/model_extract/baseroms/README.md new file mode 100644 index 0000000..21523cf --- /dev/null +++ b/model_extract/baseroms/README.md @@ -0,0 +1,25 @@ +# Put the ROM here + +`pipeline/build.py` looks for a Pokemon Stadium (US 1.0) ROM in this folder: + + model_extract/baseroms/baserom.z64 + +`.z64`, `.n64` and `.v64` byte orders are all accepted — the pipeline detects the +magic and normalises on load. Any ROM file dropped in this folder is picked up. + +Expected md5 of the US 1.0 ROM: `ed1378bc12115f71209a77844965ba50`. A different +ROM still runs, but the build prints a warning since the offsets are keyed to +this revision. + +Search order (first hit wins): + +1. `model_extract/baseroms/baserom.z64` +2. `model_extract/baseroms/us/baserom.z64` +3. `baseroms/us/baserom.z64` at the repo root — the location `make init` uses +4. any `*.z64` / `*.n64` / `*.v64` in this folder + +Or point at one explicitly: + + model_extract/pipeline/build.py --rom=/path/to/baserom.z64 + +The ROM is not included and is not tracked by git. diff --git a/model_extract/pipeline/README.md b/model_extract/pipeline/README.md new file mode 100644 index 0000000..ff9d9d4 --- /dev/null +++ b/model_extract/pipeline/README.md @@ -0,0 +1,93 @@ +# Export pipeline + +End-to-end: `baserom.z64` in, everything in `model_extract/` out. **Stdlib only** — +no `make init`, no splat, no crunch64, no build directory. + +```bash +model_extract/pipeline/build.py # finds the ROM automatically +model_extract/pipeline/build.py --rom=/path/to.z64 --out=/tmp/out +``` + +The ROM is looked for in `model_extract/baseroms/` first, so this folder stands +on its own; the repo's own `baseroms/us/` is the fallback. Search order: + +1. `model_extract/baseroms/baserom.z64` +2. `model_extract/baseroms/us/baserom.z64` +3. `baseroms/us/baserom.z64` at the repo root — where `make init` expects it +4. any `*.z64` / `*.n64` / `*.v64` sitting in `model_extract/baseroms/` + +`.z64`, `.n64` and `.v64` all work — the byte order is detected from the magic +and normalised on load. See [../baseroms/README.md](../baseroms/README.md). + +| flag | effect | +| --- | --- | +| `--only=3,91` | restrict to those model file numbers (fast iteration) | +| `--no-glb` | skip the glTF binaries and PNG dumps | +| `--no-js` | skip the viewer payloads and `viewer.html` | +| `--no-effects` | skip the generated fire/gas stand-ins | + +## Modules + +| file | does | +| --- | --- | +| `rom.py` | byte-order fixup (.z64/.v64/.n64), md5 check, archive unpacking, Yay0 and PERS-SZP decompression | +| `fragment.py` | FRAGMENT module → geo layout walk, F3DEX2 execution, textures, skeleton, animations | +| `battle.py` | per-species battle tables, move names, animation-context slot meanings | +| `glb.py` | glTF 2.0 binary writer | +| `effects.py` | **generated** fire/gas stand-ins (see below) | +| `build.py` | driver: ties it together, writes manifests | + +## Getting from ROM to models without the build system + +Three steps, all in `rom.py`: + +1. **Byte order.** `.z64` is native; `.v64` swaps byte pairs; `.n64` reverses + words. Detected from the magic and normalised on load. +2. **Archive.** The segment at `0x920000` starts with + `u32 tag, u32 0, u32 totalSize, u32 fileCount`, then one + `{u32 offset, u32 size, u32 pad[2]}` record per file. Only the top three + bytes of the first word are reliably zero — the model archive puts a nonzero + value in the low byte, which is the quirk `tools/unpack_asset.py` works + around too. +3. **Decompression.** Each entry is `PERS-SZP` (an 8-byte magic plus a header + size, wrapping a Yay0 stream). The Yay0 decoder is ~30 lines: a bitstream + where a 1 copies a literal byte and a 0 pulls a (distance, length) pair. + +Verified by decompressing all 215 entries and diffing against what +`make init` produces: **215/215 byte-identical**. + +The battle tables need one more hop — `D_80075BD0[species - 1]` lives in the main +code segment, so `Rom.vram_to_rom` converts `0x80075BD0` using the segment's +`start`/`vram` from the splat yaml. + +## Generated effects + +`effects.py` produces **original, procedurally generated** fire and gas. It is +not extracted game data, and everything it emits is tagged `generated: true` in +the manifest, the viewer payloads and the PNG filenames (`*_fx.png`). + +This exists because the real effects are not in the model files at all. Geo +command `0x08` attaches a callback (`func_80014A60` calls `node->unk_10`), and +the model supplies only two empty display lists and zeroed scratch buffers for +it to fill. The callback lives in another fragment and has not been ported, so +there is no flame mesh or flame texture to extract — Charmander's texture set is +eyes, claws, teeth and skin. + +The stand-ins are anchored to the exact bone the callback hangs off, so they sit +where the real effect would and follow the animation: + +| callback | species | stand-in | +| --- | --- | --- | +| `0x810000D8` | Charmander, Charmeleon, Charizard, Magmar, Moltres | tail/crest flame | +| `0x81000108` | Ponyta, Rapidash, Moltres wings | small flame | +| `0x810000E0` | Gastly (only) | gas cloud | + +Both are looping flipbooks built from tileable value noise, drawn on a pair of +crossed quads — glTF cannot billboard, so crossed quads are the portable way to +make them read from any angle. Sizes are expressed as a fraction of the model's +**height** and divided by the anchor bone's accumulated scale, so an effect comes +out the intended size wherever in the skeleton it hangs. Seeds derive from the +species number, so a given Pokemon always generates the same effect. + +In the viewer they draw in a second pass with depth writes off — additive for +fire, alpha for gas — and there is a *generated effects* toggle to hide them. diff --git a/model_extract/pipeline/battle.py b/model_extract/pipeline/battle.py new file mode 100644 index 0000000..9ed8ec3 --- /dev/null +++ b/model_extract/pipeline/battle.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +""" +Per-species battle data. + +`func_84302658` in src/fragments/62 DMAs a 0xB90-byte table per species out of +the 0x70D3A0 segment, addressed through the D_80075BD0 pointer table. It is an +array of 0x10-byte entries: byte 0 is an index into that Pokemon's animation +list, byte 1 indexes the auxiliary (texture) animation list. + + entries 0..164 one per move, so entry n drives move n + 1 + entries 165+ fixed battle contexts + +Every one of the 151 species' tables indexes only animations that species +actually has, which is what confirms the layout. +""" +import os +import struct + +STRIDE = 0xB90 +ENTRY = 0x10 +N_MOVES = 165 + +# `evidence` records how far each label can be trusted: +# code - the battle code in src/fragments/62 names the slot outright +# data - inferred from what the referenced animation does, measured over all +# 151 species +CONTEXT_SLOTS = { + 165: ('idle', 'code', + 'The standby loop. func_8432B0A4 restores this slot whenever the Pokemon ' + 'returns to neutral, and it resolves to animation 0 for all 151 species.'), + 166: ('hit', 'data', + 'Resolves to animation 2 for 149/151 species, the same animation slots ' + '178-181 use in the reaction paths.'), + 167: ('faint', 'data', + 'The referenced animation always ends far from the standing pose - the ' + 'model collapses to 0.03-0.84x its idle height, or leaves the frame ' + 'entirely for fliers.'), + 168: ('entrance', 'code+data', + 'The default slot in func_8430506C / func_8432AF70. The referenced ' + 'animation ends at exactly idle height, so it is a full cycle that ' + 'settles back into the standby pose.'), + 169: ('reaction_169', 'data', 'Resolves to the idle animation for 139/151 species.'), + 170: ('reaction_170', 'data', 'Split between the idle and hit animations.'), + 171: ('reaction_171', 'data', 'Resolves to the idle animation for 138/151 species.'), + 172: ('reaction_172', 'data', 'Resolves to the idle animation for 148/151 species.'), + 173: ('reaction_173', 'data', 'Resolves to the hit animation for 97/151 species.'), + 174: ('reaction_174', 'data', 'Resolves to the idle animation for 138/151 species.'), + 175: ('struggle', 'code', 'Passed as slot 0xAF to func_84305A74.'), + 176: ('idle_alt', 'code', + 'Substituted for the idle slot when battle flag 0x200 is set.'), + 177: ('faint_alt', 'data', 'Same animation as slot 167 for almost every species.'), + 178: ('flinch', 'code', + 'Used when the incoming move is one of the two listed in D_84384598.'), + 179: ('reaction_179', 'data', 'Resolves to the hit animation for all 151 species.'), + 180: ('reaction_180', 'data', 'Resolves to the hit animation for all 151 species.'), + 181: ('reaction_181', 'data', 'Resolves to the hit animation for all 151 species.'), + 182: ('reaction_182', 'data', + 'Resolves to animation 0 for 136 species and animation 1 for the other 15.'), + 183: ('entrance_alt', 'data', 'Same animation as slot 168 for every species.'), + 184: ('idle_return', 'code', 'Passed as slot 0xB8 to func_84305A74.'), +} + +MOVE_CONSTANTS = 'oldnotes/stadium1/constants/move_constants.s' + + +def load_move_names(repo_root='.'): + """Move IDs come from the repo's own extracted constants when available.""" + path = os.path.join(repo_root, MOVE_CONSTANTS) + names = {} + if os.path.exists(path): + for line in open(path, encoding='utf-8', errors='replace'): + # the file also carries an ABC_* section indexing moves alphabetically + # by their Japanese names; only the real move IDs are wanted + if ' EQU ' not in line or line.startswith('ABC_'): + continue + name, val = line.split(' EQU ') + val = val.split(';', 1)[0].strip() + if val: + names[int(val, 0)] = name.strip().replace('_', ' ').title() + return {i: names.get(i, f'Move {i}') for i in range(1, N_MOVES + 1)} + + +class BattleTables: + def __init__(self, rom): + from rom import BATTLE_DATA, PTR_TABLE_VRAM + self.rom = rom + self.base = BATTLE_DATA + self.ptr_table = rom.vram_to_rom(PTR_TABLE_VRAM) + + def offset(self, species): + raw = self.rom.u32(self.ptr_table + (species - 1) * 4) + return self.base + (raw & 0xFFFFFF) + + def rows(self, species): + """Returns [(animIndex, auxIndex)] for every entry, aux 0xFF -> -1.""" + o = self.offset(species) + out = [] + for e in range(STRIDE // ENTRY): + anim = self.rom.data[o + e * ENTRY] + aux = self.rom.data[o + e * ENTRY + 1] + out.append((anim, -1 if aux == 0xFF else aux)) + return out + + +SPECIES = {} +_NAMES = ( + "Bulbasaur Ivysaur Venusaur Charmander Charmeleon Charizard Squirtle Wartortle Blastoise " + "Caterpie Metapod Butterfree Weedle Kakuna Beedrill Pidgey Pidgeotto Pidgeot Rattata Raticate " + "Spearow Fearow Ekans Arbok Pikachu Raichu Sandshrew Sandslash NidoranF Nidorina Nidoqueen " + "NidoranM Nidorino Nidoking Clefairy Clefable Vulpix Ninetales Jigglypuff Wigglytuff Zubat " + "Golbat Oddish Gloom Vileplume Paras Parasect Venonat Venomoth Diglett Dugtrio Meowth Persian " + "Psyduck Golduck Mankey Primeape Growlithe Arcanine Poliwag Poliwhirl Poliwrath Abra Kadabra " + "Alakazam Machop Machoke Machamp Bellsprout Weepinbell Victreebel Tentacool Tentacruel Geodude " + "Graveler Golem Ponyta Rapidash Slowpoke Slowbro Magnemite Magneton Farfetchd Doduo Dodrio " + "Seel Dewgong Grimer Muk Shellder Cloyster Gastly Haunter Gengar Onix Drowzee Hypno Krabby " + "Kingler Voltorb Electrode Exeggcute Exeggutor Cubone Marowak Hitmonlee Hitmonchan Lickitung " + "Koffing Weezing Rhyhorn Rhydon Chansey Tangela Kangaskhan Horsea Seadra Goldeen Seaking " + "Staryu Starmie MrMime Scyther Jynx Electabuzz Magmar Pinsir Tauros Magikarp Gyarados Lapras " + "Ditto Eevee Vaporeon Jolteon Flareon Porygon Omanyte Omastar Kabuto Kabutops Aerodactyl " + "Snorlax Articuno Zapdos Moltres Dratini Dragonair Dragonite Mewtwo Mew").split() +for _i, _n in enumerate(_NAMES): + SPECIES[_i + 1] = _n diff --git a/model_extract/pipeline/build.py b/model_extract/pipeline/build.py new file mode 100644 index 0000000..38b0e9a --- /dev/null +++ b/model_extract/pipeline/build.py @@ -0,0 +1,345 @@ +#!/usr/bin/env python3 +""" +End-to-end export: baserom.z64 -> glb / textures / viewer payloads / manifests. + + model_extract/pipeline/build.py [--rom PATH] [--out DIR] [options] + +Stdlib only. Nothing here needs `make init`, splat or crunch64 -- the ROM is +read, decompressed and parsed directly. + +Options: + --no-js skip the viewer payloads and viewer.html + --no-glb skip the glTF binaries and PNG dumps + --no-effects skip the generated fire/gas stand-ins + --only N[,N] restrict to these model file numbers (for quick iteration) +""" +import base64 +import collections +import json +import math +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +REPO = os.path.abspath(os.path.join(HERE, '..', '..')) + +import battle +import effects as fx_gen +import fragment +import glb as glb_mod +import rom as rom_mod + +N_POKEMON = 151 +EXTRA_NAMES = {152: 'Surfing Pikachu'} # only the one identified with confidence + +# Where to look for the ROM, in order. model_extract/baseroms/ comes first so the +# folder can stand on its own; the repo's own baseroms/ is the fallback. +BASEROMS = os.path.join(os.path.dirname(HERE), 'baseroms') +ROM_CANDIDATES = [ + os.path.join(BASEROMS, 'baserom.z64'), + os.path.join(BASEROMS, 'us', 'baserom.z64'), + os.path.join(REPO, 'baseroms', 'us', 'baserom.z64'), +] + + +def find_rom(): + for p in ROM_CANDIDATES: + if os.path.exists(p): + return p + if os.path.isdir(BASEROMS): # any ROM dropped in the folder + for f in sorted(os.listdir(BASEROMS)): + if f.lower().endswith(('.z64', '.n64', '.v64')): + return os.path.join(BASEROMS, f) + return None + + +# --------------------------------------------------------------- bind extent + +def bind_extent(data): + """World-space bounding box of the bind pose, used to size the effects.""" + def trs(t, r, s): + S = lambda v: math.sin(v / 32768 * math.pi) + C = lambda v: math.cos(v / 32768 * math.pi) + sx, cx = S(r[0]), C(r[0]); sy, cy = S(r[1]), C(r[1]); sz, cz = S(r[2]), C(r[2]) + return [cy*cz*s[0], cy*sz*s[0], -sy*s[0], 0, + (sx*sy*cz-cx*sz)*s[1], (sx*sy*sz+cx*cz)*s[1], sx*cy*s[1], 0, + (cx*sy*cz+sx*sz)*s[2], (cx*sy*sz-sx*cz)*s[2], cx*cy*s[2], 0, + t[0], t[1], t[2], 1] + + def mul(a, b): + r = [0]*16 + for c in range(4): + for i in range(4): + r[c*4+i] = a[i]*b[c*4] + a[4+i]*b[c*4+1] + a[8+i]*b[c*4+2] + a[12+i]*b[c*4+3] + return r + + root = trs([0, 0, 0], [0, 0, 0], data['rootScale']) + acc, uns, mats = [], [], [] + for b in data['bones']: + pa = acc[b['parent']] if b['parent'] >= 0 else [1.0, 1.0, 1.0] + pu = uns[b['parent']] if b['parent'] >= 0 else root + u = mul(pu, trs([b['t'][k]*pa[k] for k in range(3)], b['r'], [1, 1, 1])) + a = [pa[k]*b['s'][k] for k in range(3)] + m = list(u) + for k in range(4): + m[k] *= a[0]; m[4+k] *= a[1]; m[8+k] *= a[2] + acc.append(a); uns.append(u); mats.append(m) + + lo = [1e9]*3; hi = [-1e9]*3 + for p in data['prims']: + for i, bi in enumerate(p['skin']): + m = mats[bi] + x, y, z = p['pos'][i*3:i*3+3] + w = (m[0]*x+m[4]*y+m[8]*z+m[12], m[1]*x+m[5]*y+m[9]*z+m[13], + m[2]*x+m[6]*y+m[10]*z+m[14]) + for k in range(3): + lo[k] = min(lo[k], w[k]); hi[k] = max(hi[k], w[k]) + # height, not the largest dimension: sizing off the max would scale Moltres' + # flames to its wingspan + extent = (hi[1] - lo[1]) if lo[0] <= hi[0] else 1.0 + # how much each bone scales its own local space, so effects can compensate + scales = [math.sqrt(m[0]*m[0] + m[1]*m[1] + m[2]*m[2]) for m in mats] + return extent, scales + + +# ------------------------------------------------------------ animation names + +# Which context name wins when several claim the same animation. The battle +# table points many slots at one clip, and these are the ones worth naming. +NAME_PREF = ['idle', 'hit', 'faint', 'entrance', 'struggle', 'flinch'] + + +def label_animations(data, rows, moves): + """Name each animation after what the battle table uses it for, and pair it + with the texture animation that table most often sets alongside it. + + Mutates `data['anims']`, giving each a `name` and an `aux`, and hands back + the per-animation context and move lists the manifest reports. Factored out + of main() because the mod's packer (tools/stadium_pack.py) has to label them + exactly the same way for its output to be comparable with the Lua extractor + that reads the same ROM at runtime. + """ + entries = [r[0] for r in rows] + aux = [r[1] for r in rows] + uses = [[] for _ in data['anims']] + move_uses = [[] for _ in data['anims']] + for e, ai in enumerate(entries): + if ai >= len(uses): + continue + if e < battle.N_MOVES: + move_uses[ai].append(moves[e + 1]) + elif e in battle.CONTEXT_SLOTS: + uses[ai].append(battle.CONTEXT_SLOTS[e][0]) + pairs = [collections.Counter() for _ in data['anims']] + for e, ai in enumerate(entries): + if ai < len(pairs) and 0 <= aux[e] < len(data['auxAnims']): + pairs[ai][aux[e]] += 1 + for i, a in enumerate(data['anims']): + ctx = sorted(set(uses[i])) + named = [n for n in NAME_PREF if n in ctx] + a['name'] = (named[0] if named else 'attack' if move_uses[i] + else ctx[0] if ctx else f'anim{i}') + a['aux'] = pairs[i].most_common(1)[0][0] if pairs[i] else -1 + seen = {} + for a in data['anims']: + n = seen.get(a['name'], 0) + seen[a['name']] = n + 1 + if n: + a['name'] = f'{a["name"]}_{n + 1}' + return uses, move_uses + + +def attach_effects(data, species, raw=False): + """Append generated fire/gas prims + their flipbook textures. + + `raw` matches fragment.extract's: the flipbook frames are already RGBA8, so + they are stored as-is rather than encoded, for the packer that wants pixels. + """ + if not data.get('fx'): + return 0 + extent, bone_scale = bind_extent(data) + made = fx_gen.build_for(species, data['fx'], extent, bone_scale) + for e in made: + first = len(data['textures']) + for i, frame in enumerate(e['frames']): + rec = dict(index=-1, w=e['w'], h=e['h'], generated=True) + if raw: + rec['rgba'] = frame + else: + rec['png'] = ('data:image/png;base64,' + base64.b64encode( + fragment.png(e['w'], e['h'], frame)).decode()) + data['textures'].append(rec) + g = e['geo'] + data['prims'].append(dict( + tex=first, cull=0, texAnim=-1, texMap={}, + generated=True, effect=e['kind'], + blend='add' if e['kind'] == 'fire' else 'alpha', + fxFrames=list(range(first, first + len(e['frames']))), + pos=g['pos'], uv=g['uv'], nrm=g['nrm'], skin=g['skin'], idx=g['idx'])) + return len(made) + + +# --------------------------------------------------------------------- main + +def main(argv): + args = {a.split('=')[0]: (a.split('=', 1)[1] if '=' in a else True) for a in argv} + rom_path = args.get('--rom') or find_rom() + outdir = args.get('--out') or os.path.join(REPO, 'model_extract') + want_js = '--no-js' not in args + want_glb = '--no-glb' not in args + want_fx = '--no-effects' not in args + only = {int(x) for x in args['--only'].split(',')} if '--only' in args else None + + if not rom_path or not os.path.exists(rom_path): + sys.exit('ROM not found. Put a Pokemon Stadium (US 1.0) ROM at\n' + f' {os.path.join(BASEROMS, "baserom.z64")}\n' + 'or pass --rom=PATH. Searched:\n ' + + '\n '.join(ROM_CANDIDATES)) + + print(f'reading {rom_path}') + rom = rom_mod.Rom(rom_path) + print(f' md5 {rom.md5}' + ('' if rom.is_expected_us else ' (NOT the expected US 1.0 ROM)')) + + blobs = rom_mod.pokemon_models(rom) + print(f' {len(blobs)} model fragments') + tables = battle.BattleTables(rom) + moves = battle.load_move_names(REPO) + + for sub in ('glb', 'textures', 'js'): + os.makedirs(os.path.join(outdir, sub), exist_ok=True) + + manifest = dict( + source='Pokemon Stadium (US) 1.0', romMd5=rom.md5, + generator='model_extract/pipeline/build.py', + coordinateSystem='Y up, +Z front, units are game units (models authored 10x, ' + 'baked into the model_root node scale)', + frameRate=30, + generatedEffects='Prims and textures tagged generated:true are NOT extracted ' + 'game data -- see pipeline/effects.py.', + animationSlots={str(k): dict(name=v[0], evidence=v[1], description=v[2]) + for k, v in battle.CONTEXT_SLOTS.items()}, + pokemon=[], extra=[]) + move_rows, anim_names, index, fx_count = {}, {}, [], 0 + + for fileno, blob in enumerate(blobs): + if only is not None and fileno not in only: + continue + try: + data = fragment.extract(blob, f'{fileno}.bin') + except Exception as exc: + print(f' skip {fileno}: {exc}') + continue + species = data['species'] + pokemon = fileno < N_POKEMON + + if pokemon: + rows = tables.rows(species) + uses, move_uses = label_animations(data, rows, moves) + slug = f'{species:03d}_{battle.SPECIES.get(species, str(species)).lower()}' + data['name'] = battle.SPECIES.get(species, f'#{species}') + move_rows[species] = [[rows[e][0], rows[e][1]] + for e in range(battle.N_MOVES)] + anim_names[species] = [a['name'] for a in data['anims']] + else: + slug = f'x{fileno:03d}_model' + data['name'] = EXTRA_NAMES.get(fileno, f'Model {fileno}') + for i, a in enumerate(data['anims']): + a['name'] = f'anim{i}' + a['aux'] = 0 if data['auxAnims'] else -1 + + nfx = attach_effects(data, species) if want_fx else 0 + fx_count += nfx + + pngs = [base64.b64decode(t['png'].split(',', 1)[1]) for t in data['textures']] + if want_glb: + with open(os.path.join(outdir, 'glb', slug + '.glb'), 'wb') as fp: + fp.write(glb_mod.build_glb(data, pngs)) + texdir = os.path.join(outdir, 'textures', slug) + os.makedirs(texdir, exist_ok=True) + for i, (t, p) in enumerate(zip(data['textures'], pngs)): + tag = '_fx' if t.get('generated') else '' + with open(os.path.join(texdir, f'{i:02d}_{t["w"]}x{t["h"]}{tag}.png'), 'wb') as fp: + fp.write(p) + if want_js: + with open(os.path.join(outdir, 'js', slug + '.js'), 'w') as fp: + fp.write('PKMN_LOAD(' + json.dumps(data, separators=(',', ':')) + ');\n') + + entry = dict( + species=species, name=data['name'], slug=slug, + group='pokemon' if pokemon else 'extra', + sourceFile=f'{fileno}.bin', glb=f'glb/{slug}.glb', + textureDir=f'textures/{slug}', + triangles=sum(len(p['idx']) // 3 for p in data['prims']), + vertices=sum(len(p['pos']) // 3 for p in data['prims']), + bones=len(data['bones']), textures=len(data['textures']), + generatedEffects=nfx, + animations=[dict( + index=i, name=a['name'], frames=a['frames'], + seconds=round(a['frames'] / 30.0, 3), + endBehavior='clamp' if (a['flags'] & 2) else 'wrap', + loopStartFrame=a['loopStart'], + **(dict(contexts=sorted(set(uses[i])), + moves=sorted(set(move_uses[i])), + moveCount=len(set(move_uses[i]))) if pokemon else {})) + for i, a in enumerate(data['anims'])]) + (manifest['pokemon'] if pokemon else manifest['extra']).append(entry) + index.append(dict(species=species, name=data['name'], slug=slug, + group=entry['group'], triangles=entry['triangles'], + bones=entry['bones'], animations=len(data['anims']))) + print(f' {slug:<22} {len(data["anims"]):2d} anims {entry["triangles"]:5d} tris' + + (f' +{nfx} effect' if nfx else '')) + + # ---- move index ------------------------------------------------------ + moves_out = [] + if move_rows: + default_anim = {p['species']: next( + (a['index'] for a in p['animations'] if 'hit' in a.get('contexts', [])), -1) + for p in manifest['pokemon']} + for mid in range(1, battle.N_MOVES + 1): + users, tally, ndiff = [], collections.Counter(), 0 + for sp in sorted(move_rows): + ai, ax = move_rows[sp][mid - 1] + name = anim_names[sp][ai] if ai < len(anim_names[sp]) else f'anim{ai}' + diff = ai != default_anim.get(sp, -1) + ndiff += diff + tally[name] += 1 + users.append(dict(species=sp, animation=ai, animationName=name, + aux=ax, differsFromDefault=diff)) + moves_out.append(dict( + id=mid, name=moves[mid], speciesWithOwnAnimation=ndiff, + animationNames=[dict(name=n, species=c) for n, c in tally.most_common()], + users=users)) + with open(os.path.join(outdir, 'moves.json'), 'w') as fp: + json.dump(dict(source=manifest['source'], note=( + 'Entry n of the per-species battle table (0-indexed) selects the ' + 'animation played when that Pokemon uses move n+1. The table is dense ' + '- every species has a row for every move, including moves it can ' + 'never learn - and those unreachable rows overwhelmingly point at the ' + 'species\' generic reaction animation. Use differsFromDefault.'), + moves=moves_out), fp, indent=1) + + with open(os.path.join(outdir, 'manifest.json'), 'w') as fp: + json.dump(manifest, fp, indent=2) + + if want_js: + with open(os.path.join(outdir, 'js', 'index.js'), 'w') as fp: + fp.write('window.PKMN_INDEX = ' + json.dumps(index, separators=(',', ':')) + ';\n') + if moves_out: + with open(os.path.join(outdir, 'js', 'moves.js'), 'w') as fp: + fp.write('window.PKMN_MOVES = ' + json.dumps( + [dict(id=m['id'], name=m['name'], + bySpecies={str(u['species']): [u['animation'], u['aux']] + for u in m['users']}) for m in moves_out], + separators=(',', ':')) + ';\n') + viewer_src = os.path.join(REPO, 'tools/model_viewer/viewer.html') + if os.path.exists(viewer_src): + with open(viewer_src) as s, open(os.path.join(outdir, 'viewer.html'), 'w') as d: + d.write(s.read()) + + print(f'\n{len(manifest["pokemon"])} Pokemon + {len(manifest["extra"])} other models' + f', {fx_count} generated effects') + + +if __name__ == '__main__': + main(sys.argv[1:]) diff --git a/model_extract/pipeline/effects.py b/model_extract/pipeline/effects.py new file mode 100644 index 0000000..d718f97 --- /dev/null +++ b/model_extract/pipeline/effects.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +""" +Generated stand-in effects. + +IMPORTANT: nothing in this file is extracted game data. The real tail flame, +mane fire and gas are drawn by procedural callbacks that live in another +fragment (geo command 0x08 -> func_80014A60 calls node->unk_10, and the model +file supplies only two empty display lists plus zeroed scratch buffers). Those +callbacks have not been ported, so the models genuinely contain no flame mesh +and no flame texture. + +What follows is an original, procedurally generated replacement: looping +flipbook textures plus a pair of crossed quads anchored to the bone the +callback hangs off. It is meant to make the models look right in the viewer, +and it is tagged `generated: true` everywhere it appears so it is never +mistaken for ripped content. +""" +import math + +# geo cmd 0x08 callback ids -> which effect to stand in for. The grouping is the +# game's own: every species sharing a callback shares an effect. +FIRE_TAIL = 0x810000D8 # Charmander, Charmeleon, Charizard, Magmar, Moltres +FIRE_SMALL = 0x81000108 # Ponyta, Rapidash, Moltres wings +AURA = 0x810000E0 # Gastly, Koffing, Weezing, Vaporeon, Articuno, Moltres + + +class Rng: + """Deterministic PRNG so a given species always generates the same effect.""" + + def __init__(self, seed): + self.s = seed & 0xFFFFFFFF or 0x9E3779B9 + + def next(self): + x = self.s + x ^= (x << 13) & 0xFFFFFFFF + x ^= x >> 17 + x ^= (x << 5) & 0xFFFFFFFF + self.s = x & 0xFFFFFFFF + return self.s + + def unit(self): + return self.next() / 0x100000000 + + +def _lattice(rng, w, h): + return [[rng.unit() for _ in range(w)] for _ in range(h)] + + +def _smooth(t): + return t * t * (3 - 2 * t) + + +def _sample(grid, x, y): + """Bilinear value noise on a torus, so the field tiles in both axes.""" + h, w = len(grid), len(grid[0]) + x0, y0 = int(math.floor(x)) % w, int(math.floor(y)) % h + x1, y1 = (x0 + 1) % w, (y0 + 1) % h + fx, fy = _smooth(x - math.floor(x)), _smooth(y - math.floor(y)) + a = grid[y0][x0] + (grid[y0][x1] - grid[y0][x0]) * fx + b = grid[y1][x0] + (grid[y1][x1] - grid[y1][x0]) * fx + return a + (b - a) * fy + + +def _fbm(grids, x, y, scale): + """Sum octaves of tileable noise.""" + total, amp, norm = 0.0, 1.0, 0.0 + for i, g in enumerate(grids): + f = scale * (2 ** i) + total += _sample(g, x * f, y * f) * amp + norm += amp + amp *= 0.5 + return total / norm + + +def _ramp(stops, t): + t = max(0.0, min(1.0, t)) + for i in range(len(stops) - 1): + a, b = stops[i], stops[i + 1] + if t <= b[0]: + k = 0.0 if b[0] == a[0] else (t - a[0]) / (b[0] - a[0]) + return tuple(int(a[1 + j] + (b[1 + j] - a[1 + j]) * k) for j in range(4)) + return tuple(stops[-1][1:]) + + +FIRE_RAMP = [ # intensity -> RGBA + (0.00, 0, 0, 0, 0), + (0.30, 120, 24, 8, 90), + (0.52, 226, 78, 16, 205), + (0.74, 252, 176, 44, 245), + (1.00, 255, 246, 214, 255), +] + +GAS_RAMP = [ + (0.00, 0, 0, 0, 0), + (0.34, 52, 26, 78, 70), + (0.60, 96, 52, 140, 140), + (0.82, 148, 96, 196, 190), + (1.00, 208, 176, 236, 215), +] + + +def fire_frames(seed, w=32, h=64, frames=8, wisp=1.0): + """Upward-advected noise plume. Scrolling by an exact multiple of the noise + lattice over the frame count makes the loop seamless.""" + rng = Rng(seed) + grids = [_lattice(rng, 8, 8), _lattice(rng, 16, 16), _lattice(rng, 32, 32)] + out = [] + for f in range(frames): + t = f / frames + buf = bytearray(w * h * 4) + for y in range(h): + v = y / (h - 1) # 0 at the base, 1 at the tip + # plume envelope: wide and hot at the base, pinched at the tip + taper = max(0.0, 1.0 - v) ** 0.42 + for x in range(w): + u = (x / (w - 1)) * 2 - 1 # -1 .. 1 across the flame + radial = (1.0 - min(1.0, abs(u) / max(0.10, taper * 0.95))) ** 0.7 + if radial <= 0: + continue + n = _fbm(grids, x / w, (y / h) - t, 3.0) + lick = 0.55 + 0.75 * (n - 0.5) * wisp + inten = radial * (0.55 + 0.8 * taper) * lick + inten -= 0.16 * v # cool towards the tip + if inten <= 0.02: + continue + r, g, b, a = _ramp(FIRE_RAMP, inten) + i = ((h - 1 - y) * w + x) * 4 # +Y in texture space is up + buf[i:i+4] = bytes((r, g, b, a)) + out.append(bytes(buf)) + return w, h, out + + +def gas_frames(seed, w=48, h=48, frames=10): + """Slow swirling haze that fades out towards the rim.""" + rng = Rng(seed) + grids = [_lattice(rng, 8, 8), _lattice(rng, 16, 16), _lattice(rng, 32, 32)] + out = [] + for f in range(frames): + t = f / frames + buf = bytearray(w * h * 4) + ang = t * 2 * math.pi + for y in range(h): + for x in range(w): + dx = (x / (w - 1)) * 2 - 1 + dy = (y / (h - 1)) * 2 - 1 + d = math.hypot(dx, dy) + if d >= 1.0: + continue + falloff = (1.0 - d) ** 0.85 + # rotate the sample point so the haze churns without popping + sx = dx * math.cos(ang) - dy * math.sin(ang) + sy = dx * math.sin(ang) + dy * math.cos(ang) + n = _fbm(grids, sx * 0.5 + 0.5, sy * 0.5 + 0.5 - t, 2.5) + inten = falloff * (0.78 + 1.30 * (n - 0.44)) + if inten <= 0.03: + continue + r, g, b, a = _ramp(GAS_RAMP, inten) + i = (y * w + x) * 4 + buf[i:i+4] = bytes((r, g, b, a)) + out.append(bytes(buf)) + return w, h, out + + +def crossed_quads(bone, length, width, axis='y', centred=False): + """Two quads at right angles so the effect reads from any angle -- the + portable stand-in for a billboard, since glTF cannot billboard. + + `axis` picks which bone-local direction the quad grows along. Bone-local +X + runs down the limb, so a flame laid out along X comes out lying sideways; + 'y' is that same quad rotated 90 degrees left about Z, which stands it up. + `centred` straddles the origin instead of growing from it.""" + pos, uv, nrm, skin, idx = [], [], [], [], [] + for q in range(2): + base = len(pos) // 3 + for (s, t) in ((0, 0), (1, 0), (1, 1), (0, 1)): + a = (s - 0.5) * width + b = (t - 0.5) * length if centred else t * length + if axis == 'x': + p = (b, a, 0.0) if q == 0 else (b, 0.0, a) + else: # (x, y) -> (-y, x) + p = (-a, b, 0.0) if q == 0 else (0.0, b, a) + pos += list(p) + uv += [s, 1.0 - t] + nrm += [0.0, 0.0, 1.0] if q == 0 else [1.0, 0.0, 0.0] + skin.append(bone) + idx += [base, base + 1, base + 2, base, base + 2, base + 3] + return dict(pos=pos, uv=uv, nrm=nrm, skin=skin, idx=idx) + + +# desired size as a fraction of the model's world-space extent +SIZES = { + 'fire_tail': (0.40, 0.22), # length, width + 'fire_small': (0.075, 0.042), + 'gas': (1.05, 1.05), +} + + +def build_for(species, fx, extent, bone_scale): + """Returns [{kind, bone, geo, w, h, frames}] for one model, or []. + + `extent` is the model's world-space size and `bone_scale[i]` how much bone i + already scales its local space; dividing by it keeps every effect the size we + asked for regardless of where in the skeleton it hangs.""" + out = [] + for node in fx: + cb, bone = node['callback'], node['bone'] + if bone < 0 or bone >= len(bone_scale): + continue + k = bone_scale[bone] or 1.0 + if cb == FIRE_TAIL: + fl, fw = SIZES['fire_tail'] + w, h, fr = fire_frames(species * 7919 + 1, 32, 64, 8) + geo = crossed_quads(bone, extent * fl / k, extent * fw / k, axis='y') + out.append(dict(kind='fire', bone=bone, geo=geo, w=w, h=h, frames=fr)) + elif cb == FIRE_SMALL: + fl, fw = SIZES['fire_small'] + w, h, fr = fire_frames(species * 6271 + bone, 24, 40, 8, wisp=1.25) + geo = crossed_quads(bone, extent * fl / k, extent * fw / k, axis='y') + out.append(dict(kind='fire', bone=bone, geo=geo, w=w, h=h, frames=fr)) + elif cb == AURA and species == 92: # Gastly only + fl, fw = SIZES['gas'] + w, h, fr = gas_frames(species * 5237 + 3, 48, 48, 10) + geo = crossed_quads(bone, extent * fl / k, extent * fw / k, axis='y', centred=True) + out.append(dict(kind='gas', bone=bone, geo=geo, w=w, h=h, frames=fr)) + return out diff --git a/model_extract/pipeline/fragment.py b/model_extract/pipeline/fragment.py new file mode 100644 index 0000000..f87850d --- /dev/null +++ b/model_extract/pipeline/fragment.py @@ -0,0 +1,735 @@ +#!/usr/bin/env python3 +""" +Model extraction: FRAGMENT module -> geometry, textures, skeleton, animations. + +Self-contained copy of tools/model_viewer/extract_model.py, taking raw bytes so +it can be fed straight from the ROM. See ../README.md for the format notes. +""" +import json, os, struct, sys, zlib + +BASE = 0x8FF00000 + +# ---------------------------------------------------------------- geo layout + +CMD_SIZES = { + 0x00:0x08, 0x01:0x04, 0x02:0x08, 0x03:0x08, 0x04:0x04, 0x05:0x04, 0x06:0x04, + 0x07:0x08, 0x08:0x0C, 0x09:0x04, 0x0A:0x08, 0x0B:0x18, 0x0C:0x04, 0x0D:0x04, + 0x0E:0x04, 0x0F:0x04, 0x10:0x04, 0x11:0x04, 0x12:0x04, 0x13:0x08, 0x14:0x0C, + 0x15:0x0C, 0x16:0x04, 0x17:0x14, 0x18:0x08, 0x19:0x08, 0x1A:0x04, 0x1B:0x10, + 0x1C:0x10, 0x1D:0x1C, 0x1E:0x08, 0x1F:0x18, 0x20:0x14, 0x21:0x10, 0x22:0x08, + 0x23:0x10, 0x24:0x04, 0x25:0x04, 0x26:0x14, +} + + +class Fragment: + def __init__(self, data, name=''): + self.d = data if isinstance(data, (bytes, bytearray)) else open(data, 'rb').read() + self.name = name if isinstance(data, (bytes, bytearray)) else str(data) + if self.d[8:0x10] != b'FRAGMENT': + raise ValueError(f'{self.name}: not a FRAGMENT module') + self.hdrSize, self.relocOff, self.sizeRom, self.sizeRam = struct.unpack_from('>4I', self.d, 0x10) + + def off(self, ptr): + return None if ptr == 0 else ptr - BASE + + def u8(self, o): return self.d[o] + def s8(self, o): return struct.unpack_from('>b', self.d, o)[0] + def u16(self, o): return struct.unpack_from('>H', self.d, o)[0] + def s16(self, o): return struct.unpack_from('>h', self.d, o)[0] + def u32(self, o): return struct.unpack_from('>I', self.d, o)[0] + def s32(self, o): return struct.unpack_from('>i', self.d, o)[0] + def ptr(self, o): return self.off(self.u32(o)) + + def root(self): + """The entry stub ends with `lui rX, hi; addiu rX, rX, lo` loading the root struct.""" + for o in range(0x20, 0x80, 4): + w = self.u32(o) + if (w >> 26) != 0x0F: # lui + continue + reg = (w >> 16) & 0x1F + w2 = self.u32(o + 4) + if (w2 >> 26) == 0x09 and ((w2 >> 21) & 0x1F) == reg: # addiu rX, rX, imm + return ((self.u16(o + 2) << 16) + self.s16(o + 6)) - BASE + raise RuntimeError('could not locate root struct') + + def ptr_list(self, o): + out = [] + while True: + p = self.ptr(o) + if p is None: + return out + out.append(p) + o += 4 + + +# --------------------------------------------------------------- F3DEX2 exec + +def signed(v, bits): + m = 1 << (bits - 1) + return (v ^ m) - m + + +class Model: + """Walks the geo layout, executes the display lists, accumulates draw data.""" + + def __init__(self, frag): + self.f = frag + r = frag.root() + self.species = frag.u16(r) + self.geoLayouts = frag.ptr_list(frag.ptr(r + 0x08)) + self.anims = frag.ptr_list(frag.ptr(r + 0x0C)) + self.auxAnims = frag.ptr_list(frag.ptr(r + 0x10)) + + self.textures = [] # {fmt, siz, w, h, texels, data} + self.tluts = [] # palettes: {count, data, dl} + self.bones = [] # {parent, boneId, chan, t, r, s} + self.boneById = {} + self.prims = [] # {tex, cull, verts:[...], tris:[...]} + self.primsByKey = {} + self.vtxBase = None + self.rootScale = [1.0, 1.0, 1.0] + self.fx = [] # geo cmd 0x08 procedural effect nodes + self.warnings = [] + + # ---- textures ------------------------------------------------------- + def read_texture_table(self, off, count): + f = self.f + for i in range(count): + o = off + i * 0xC + self.textures.append(dict( + fmt=f.u8(o), siz=f.u8(o + 1), w=f.s16(o + 2), + h=f.u16(o + 4), texels=f.u16(o + 6), data=f.ptr(o + 8))) + + def read_tlut_table(self, off, count): + """Palettes reuse the texture-record layout: the count sits in the width + field, the palette data in the next word, and unk_08 is the DL that loads + it (src/12D80.c func_80015B20). The DL is authoritative, so run it.""" + f = self.f + for i in range(count): + o = off + i * 0xC + rec = dict(count=f.u16(o + 2), data=f.ptr(o + 4), dl=f.ptr(o + 8)) + dl = rec['dl'] + if dl is not None: + for _ in range(16): + w0, w1 = struct.unpack_from('>II', f.d, dl) + op = w0 >> 24 + if op == 0xFD: # G_SETTIMG + rec['data'] = f.off(w1) + elif op == 0xF0: # G_LOADTLUT + rec['count'] = ((w1 >> 14) & 0x3FF) + 1 + elif op == 0xDF: + break + dl += 8 + self.tluts.append(rec) + + # ---- geo layout ----------------------------------------------------- + def build(self): + self.curTex = -1 + self.curTlut = -1 + self.curMat = None + self.curTexAnim = -1 + # Mirrors gCurGraphNodeList in src/geo_layout.c: stack[-1] is the slot the + # next node command writes to, and a node's parent -- and the bone whose + # matrix is live -- is the slot *below* it (func_80017AC4). + self.stack = [-1] + # The RSP vertex cache persists across display lists: a bone's list often + # preloads verts that the *next* bone's list then indexes, which is how + # these models get blended joints. Each slot remembers the bone whose + # matrix was current when it was loaded. + self.vbuf = [None] * 64 + self.walk(self.geoLayouts[0]) + + def walk(self, o, depth=0): + f = self.f + if depth > 32: + return + while True: + cmd = f.u8(o) + size = CMD_SIZES.get(cmd) + if size is None: + self.warnings.append(f'unknown geo cmd {cmd:#04x} at {o:#x}') + return + if cmd == 0x01 or cmd == 0x04: # end / return + return + if cmd in (0x00, 0x03): # branch (with return) + self.walk(f.ptr(o + 4), depth + 1) + elif cmd == 0x02: # jump (no return) + o = f.ptr(o + 4) + continue + elif cmd == 0x05: # open node + self.stack.append(self.stack[-1]) + elif cmd == 0x06: # close node + self.stack.pop() + elif cmd == 0x17: # model header + self.read_texture_table(f.ptr(o + 8), f.s16(o + 2)) + if f.ptr(o + 0xC): + self.read_tlut_table(f.ptr(o + 0xC), f.s16(o + 4)) + self.vtxBase = f.ptr(o + 0x10) + self.nVerts = f.s16(o + 6) + elif cmd == 0x08: # procedural effect callback + self.fx.append(dict(bone=self.curBone(), callback=f.u32(o + 4), + arg=f.ptr(o + 8))) + elif cmd == 0x1C: # uniform scale node + self.rootScale = [f.s32(o + 4) / 65536.0, f.s32(o + 8) / 65536.0, + f.s32(o + 0xC) / 65536.0] + elif cmd == 0x1D: # bone / joint node + idx = len(self.bones) + self.bones.append(dict( + parent=self.curBone(), boneId=f.u8(o + 1), flags=f.u8(o + 2), + chan=f.s8(o + 3), + t=[f.s16(o + 4), f.s16(o + 6), f.s16(o + 8)], + r=[f.s16(o + 0xA), f.s16(o + 0xC), f.s16(o + 0xE)], + s=[f.s32(o + 0x10) / 65536.0, f.s32(o + 0x14) / 65536.0, + f.s32(o + 0x18) / 65536.0])) + self.boneById[f.u8(o + 1)] = idx + self.stack[-1] = idx + elif cmd == 0x23: # set texture / material + self.curTex = f.s16(o + 8) + self.curTlut = f.s16(o + 0xA) + self.curMat = f.ptr(o + 4) + # offset 0x02 is the texture-animation channel; -1 means static. + # func_800176DC swaps this material's texture per frame from the + # auxiliary animation's channel stream. + self.curTexAnim = f.s16(o + 2) + elif cmd == 0x22: # display list on current bone + self.run_dl(f.ptr(o + 4), self.curBone()) + elif cmd == 0x1E: # display list on named bone + self.run_dl(f.ptr(o + 4), self.boneById.get(f.s16(o + 2), self.curBone())) + elif cmd in (0x20, 0x21): # display list + own transform + self.run_dl(f.ptr(o + (0x10 if cmd == 0x20 else 0xC)), self.curBone()) + o += size + + def curBone(self): + return self.stack[-2] if len(self.stack) >= 2 else -1 + + # ---- display lists -------------------------------------------------- + def run_dl(self, o, bone, depth=0): + if o is None or depth > 8: + return + f = self.f + vbuf = self.vbuf + cull = 0x400 + while True: + w0, w1 = struct.unpack_from('>II', f.d, o) + op = w0 >> 24 + o += 8 + if op == 0xDF: # G_ENDDL + return + if op == 0xDE: # G_DL + self.run_dl(f.off(w1), bone, depth + 1) + if (w0 >> 16) & 0xFF: # branch, not call + return + continue + if op == 0x01: # G_VTX + n = (w0 >> 12) & 0xFF + v0 = ((w0 & 0xFFF) >> 1) - n + a = f.off(w1) + for i in range(n): + p = a + i * 0x10 + if 0 <= v0 + i < len(vbuf): + vbuf[v0 + i] = ( + f.s16(p), f.s16(p + 2), f.s16(p + 4), # position + f.s16(p + 8) / 32.0, f.s16(p + 10) / 32.0, # s, t (S10.5) + f.s8(p + 12), f.s8(p + 13), f.s8(p + 14), # normal + f.u8(p + 15), # alpha + bone) # owning bone + continue + if op == 0xD9: # G_GEOMETRYMODE + cull = (cull & (w0 & 0xFFFFFF)) | w1 + continue + if op in (0x05, 0x06): # G_TRI1 / G_TRI2 + prim = self.prim_for(self.curTex, self.curTlut, self.curMat, + self.curTexAnim, cull & 0x600) + + def emit(a, b, c): + tri = [] + for idx in (a, b, c): + v = vbuf[idx] if idx < len(vbuf) else None + if v is None: + return + j = prim['_remap'].get(v) + if j is None: + j = len(prim['verts']) + prim['_remap'][v] = j + prim['verts'].append(v) + tri.append(j) + if (cull & 0x200) and not (cull & 0x400): + tri.reverse() + prim['tris'].append(tri) + + emit(((w0 >> 16) & 0xFF) // 2, ((w0 >> 8) & 0xFF) // 2, (w0 & 0xFF) // 2) + if op == 0x06: + emit(((w1 >> 16) & 0xFF) // 2, ((w1 >> 8) & 0xFF) // 2, (w1 & 0xFF) // 2) + continue + # everything else (SETTILE / sync / ...) is state we reconstruct + # from the texture table instead, so it is skipped. + + def prim_for(self, tex, tlut, mat, texAnim, cull): + key = (tex, tlut, mat, texAnim, cull) + p = self.primsByKey.get(key) + if p is None: + p = dict(tex=tex, tlut=tlut, mat=mat, texAnim=texAnim, cull=cull, + verts=[], tris=[], _remap={}) + self.primsByKey[key] = p + self.prims.append(p) + return p + + def tile_palette(self, mat): + """CI4 selects a 16-entry block of the TLUT via the render tile's palette + field; read it from the material display list's final G_SETTILE.""" + if mat is None: + return 0 + pal = 0 + for _ in range(16): + w0, w1 = struct.unpack_from('>II', self.f.d, mat) + op = w0 >> 24 + if op == 0xF5 and ((w1 >> 24) & 7) == 0: # G_SETTILE, render tile + pal = (w1 >> 20) & 0xF + elif op == 0xDF: + break + mat += 8 + return pal + + +# ---------------------------------------------------------------- animations + +def bitfield(f, base, index, bits): + """src/F420.c func_80010500: signed `bits`-wide field at bit index*bits. + + C integer division truncates toward zero; Python's floors. That only differs + for negative indices, which is exactly what an empty channel produces, so the + truncating form is used here to match the hardware.""" + bitpos = index * bits + word = bitpos // 16 if bitpos >= 0 else -((-bitpos) // 16) # C: trunc to zero + rem = bitpos - word * 16 # C: sign follows bitpos + o = base + word * 2 + v = (f.u16(o) << 16) | f.u16(o + 2) + v = (v << (rem & 31)) & 0xFFFFFFFF # MIPS masks the shift to 5 bits + return signed(v >> (32 - bits), bits) + + +class Animation: + """src/17300.c. Two sampling modes: packed per-frame streams (default) and + hermite keyframes (flags & 8).""" + + def __init__(self, frag, off): + f = self.f = frag + self.off = off + self.flags = f.u8(off) + self.startFrame= f.u16(off + 4) + self.loopStart = f.u16(off + 6) + self.nChannels = f.u16(off + 8) + self.nFrames = f.u16(off + 0xA) + self.chanTable = f.ptr(off + 0xC) + self.scaleData = f.ptr(off + 0x10) + self.rotData = f.ptr(off + 0x14) + self.transData = f.ptr(off + 0x18) + + def chan(self, i): + o = self.chanTable + i * 0xA + f = self.f + return dict(nScale=f.u8(o), nRot=f.u8(o + 1), nTrans=f.u8(o + 2), + interp=f.u8(o + 3), oScale=f.u16(o + 4), + oRot=f.u16(o + 6), oTrans=f.u16(o + 8)) + + # -- packed stream sampling (flags & 8 == 0) -------------------------- + # A count of 0 means the component has no stream at all. The game's index + # arithmetic (offset + count - 1) then runs off the front of the array -- for + # Tangela's idle the scale "array" is two entries long and the computed index + # is 99 -- so an empty channel is treated as "keep the bind-pose value". + def _trans_packed(self, c, frame): + if c['nTrans'] == 0: + return None + bits = 16 if (self.flags & 4) else 12 + if c['nTrans'] == 1: + return float(c['oTrans'] if (self.flags & 4) else signed((c['oTrans'] * 16) & 0xFFFF, 16) >> 4) + i = c['oTrans'] + min(frame, c['nTrans'] - 1) + return float(bitfield(self.f, self.transData, i, bits)) + + def _rot_packed(self, c, frame): + if c['nRot'] == 0: + return None + if c['nRot'] == 1: + return signed((c['oRot'] * 16) & 0xFFFF, 16) + i = c['oRot'] + min(frame, c['nRot'] - 1) + return signed((bitfield(self.f, self.rotData, i, 12) * 16) & 0xFFFF, 16) + + def _scale_packed(self, c, frame): + if c['nScale'] == 0: + return None + if c['nScale'] == 1: + return c['oScale'] / 1000.0 + i = c['oScale'] + min(frame, c['nScale'] - 1) + return self.f.s16(self.scaleData + i * 2) / 1000.0 + + # -- hermite keyframe sampling (flags & 8) ---------------------------- + def _hermite(self, base, n, frame, wide): + f = self.f + stride = 8 if wide else 6 + + def key(i): + o = base + i * stride + return (f.s16(o), f.s16(o + 2), f.s16(o + 4), + f.s16(o + 6) if wide else f.s16(o + 4)) + + k0 = key(0) + if k0[0] >= frame: + return float(k0[1]) + last = key(n - 1) + if frame >= last[0]: + return float(last[1]) + i = 0 + while i < n - 2: + if frame < key(i + 1)[0]: + break + i += 1 + a, b = key(i), key(i + 1) + x = (frame - a[0]) / 30.0 + y = 30.0 / (b[0] - a[0]) + x2, x3 = x * x, x * x * x + y2, y3 = y * y, y * y * y + return (a[1] * (2 * x3 * y3 - 3 * x2 * y2 + 1) + + b[1] * (-2 * x3 * y3 + 3 * x2 * y2) + + (a[3] if wide else a[2]) * (x3 * y2 - 2 * x2 * y + x) + + b[2] * (x3 * y2 - x2 * y)) + + def _trans_key(self, c, frame): + if c['nTrans'] < 2: + return float(signed(c['oTrans'], 16)) + return self._hermite(self.transData + c['oTrans'] * 2, c['nTrans'], frame, c['interp'] & 1) + + def _rot_key(self, c, frame): + if c['nRot'] < 2: + deg = signed(c['oRot'], 16) / 10.0 + else: + deg = self._hermite(self.rotData + c['oRot'] * 2, c['nRot'], frame, c['interp'] & 2) / 10.0 + deg %= 360.0 + return int(deg / 360.0 * 65536.0) + + def _scale_key(self, c, frame): + if c['nScale'] < 2: + return signed(c['oScale'], 16) / 100.0 + return self._hermite(self.scaleData + c['oScale'] * 2, c['nScale'], frame, c['interp'] & 4) / 100.0 + + def sample_trs(self, chanIndex, frame, bind=None): + """Returns (translation, rotation, scale) triples for one bone. Components + whose channel carries no data fall back to the bone's bind value.""" + base = chanIndex * 3 + if base < 0 or base + 2 >= self.nChannels: + return None + cs = [self.chan(base + i) for i in range(3)] + if self.flags & 8: + out = ([self._trans_key(c, frame) for c in cs], + [self._rot_key(c, frame) for c in cs], + [self._scale_key(c, frame) for c in cs]) + else: + out = ([self._trans_packed(c, frame) for c in cs], + [self._rot_packed(c, frame) for c in cs], + [self._scale_packed(c, frame) for c in cs]) + if bind is None: + bind = ([0, 0, 0], [0, 0, 0], [1.0, 1.0, 1.0]) + return tuple([v if v is not None else bind[k][i] for i, v in enumerate(comp)] + for k, comp in enumerate(out)) + + +class AuxAnimation: + """Texture animation (src/18140.c). Same header shape as the skeletal + animations, but each channel is a per-frame stream of texture-table indices + that func_800176DC substitutes into a material.""" + + def __init__(self, frag, off): + f = self.f = frag + self.flags = f.u8(off) + self.startFrame= f.u16(off + 4) + self.loopStart = f.u16(off + 6) + self.nChannels = f.u16(off + 8) + self.nFrames = f.u16(off + 0xA) + self.chanTable = f.ptr(off + 0xC) + self.data = f.ptr(off + 0x10) + + def sample(self, chan, frame): + """func_80017540: index into the stream, clamped to the channel length.""" + if not (0 <= chan < self.nChannels) or self.chanTable is None: + return None + o = self.chanTable + chan * 4 + count, base = self.f.u16(o), self.f.u16(o + 2) + if count == 0: + return None + i = base + (frame if frame < count else count - 1) + return self.f.u8(self.data + i) + + def track(self, chan): + n = max(1, self.nFrames) + return [self.sample(chan, i) for i in range(n)] + + +# ------------------------------------------------------------------ textures + +def rgba5551(p): + return (((p >> 11) & 0x1F) * 255 // 31, ((p >> 6) & 0x1F) * 255 // 31, + ((p >> 1) & 0x1F) * 255 // 31, 255 if (p & 1) else 0) + + +def decode_texture(f, tex, tlut=None, palette=0): + """Returns (w, h, RGBA8 bytes) for the N64 texture formats these models use.""" + w, h, fmt, siz, addr = tex['w'], tex['h'], tex['fmt'], tex['siz'], tex['data'] + out = bytearray(w * h * 4) + d = f.d + n = w * h + + def nibble(i): + return (d[addr + i // 2] >> (0 if i & 1 else 4)) & 0xF + + if fmt == 0 and siz == 2: # RGBA16 (5/5/5/1) + for i in range(n): + out[i*4:i*4+4] = bytes(rgba5551(struct.unpack_from('>H', d, addr + i * 2)[0])) + elif fmt == 0 and siz == 3: # RGBA32 + out[:] = d[addr:addr + n * 4] + elif fmt == 2: # CI4 / CI8 -> RGBA16 palette + pal = [] + if tlut is not None and tlut['data'] is not None: + base = tlut['data'] + (palette * 16 * 2 if siz == 0 else 0) + for i in range(16 if siz == 0 else 256): + pal.append(bytes(rgba5551(struct.unpack_from('>H', d, base + i * 2)[0]))) + if not pal: + pal = [b'\xff\x00\xff\xff'] * 256 + for i in range(n): + idx = nibble(i) if siz == 0 else d[addr + i] + out[i*4:i*4+4] = pal[idx % len(pal)] + elif fmt == 3: # IA16 / IA8 / IA4 + for i in range(n): + if siz == 2: + v = struct.unpack_from('>H', d, addr + i * 2)[0] + l, a = v >> 8, v & 0xFF + elif siz == 1: + v = d[addr + i] + l, a = (v >> 4) * 17, (v & 0xF) * 17 + else: + v = nibble(i) + l, a = ((v >> 1) * 255) // 7, 255 if (v & 1) else 0 + out[i*4:i*4+4] = bytes((l, l, l, a)) + elif fmt == 4: # I8 / I4 + for i in range(n): + l = d[addr + i] if siz == 1 else nibble(i) * 17 + out[i*4:i*4+4] = bytes((l, l, l, 255)) + else: + for i in range(n): # unsupported: magenta + out[i*4:i*4+4] = b'\xff\x00\xff\xff' + return w, h, bytes(out) + + +def png(w, h, rgba): + """Minimal PNG encoder (no PIL dependency).""" + raw = b''.join(b'\x00' + rgba[y*w*4:(y+1)*w*4] for y in range(h)) + + def chunk(tag, data): + c = tag + data + return struct.pack('>I', len(data)) + c + struct.pack('>I', zlib.crc32(c) & 0xFFFFFFFF) + + return (b'\x89PNG\r\n\x1a\n' + + chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 6, 0, 0, 0)) + + chunk(b'IDAT', zlib.compress(raw, 9)) + + chunk(b'IEND', b'')) + + +# ---------------------------------------------------------------------- main + +def unique(seq): + """The distinct values of `seq`, in the order they first appear. + + Used where a set used to be. A set of small ints iterates in hash-slot + order, which is stable across runs but is neither insertion nor sort order + and is a CPython implementation detail -- and here it decided the order + textures get REGISTERED in, and so their indices in the packed file. Order + of appearance is a property of the data instead of the interpreter, which + is what lets the Lua extractor produce the same file. + """ + seen, out = set(), [] + for v in seq: + if v in seen: + continue + seen.add(v) + out.append(v) + return out + + +def dedupe_fx(nodes): + """The geo layout's effect callbacks, once each, IN THE ORDER THEY APPEAR. + + A geo layout can name the same callback on the same bone more than once + (the walk visits a subtree twice), so these have to be deduplicated, and it + used to be done by dropping them through a set. That was a real bug rather + than a style point: the set held tuples containing strings, so its iteration + order moved with PYTHONHASHSEED, and the generated flames of every species + carrying more than one -- Ponyta, Rapidash and Moltres -- came out in a + different order, with different seeds and therefore different pixels, on + different runs of the same build. + + Order of appearance is the game's own order, it is stable, and it is what + the Lua extractor can reproduce. + """ + seen, out = set(), [] + for node in nodes: + key = (node['bone'], node['callback'], node['arg']) + if key in seen: + continue + seen.add(key) + out.append(dict(node)) + return out + + +def extract(path, name=None, raw=False): + """`raw=True` carries each texture's decoded RGBA8 bytes as `rgba` instead + of encoding a PNG data URI into `png`. + + The viewer and the glTF export both want a PNG, so that stays the default. + The mod's own packer wants the pixels: it stores them uncompressed, so that + its Lua counterpart -- which has no zlib whose output is guaranteed to + agree with this one's byte for byte -- can be checked against it exactly. + """ + f = Fragment(path, name or str(path)) + m = Model(f) + m.build() + + import base64 + auxAnims = [AuxAnimation(f, o) for o in m.auxAnims] + + texIndexMap, texOut = {}, [] + + def register(texIdx, tlut, pal): + key = (texIdx, tlut, pal) + if key in texIndexMap: + return texIndexMap[key] + if not (0 <= texIdx < len(m.textures)): + return -1 + texIndexMap[key] = len(texOut) + tl = m.tluts[tlut] if 0 <= tlut < len(m.tluts) else None + w, h, rgba = decode_texture(f, m.textures[texIdx], tl, pal) + rec = dict(index=texIdx, w=w, h=h) + if raw: + rec['rgba'] = rgba + else: + rec['png'] = ('data:image/png;base64,' + + base64.b64encode(png(w, h, rgba)).decode()) + texOut.append(rec) + return texIndexMap[key] + + for p in m.prims: + if not p['tris']: + continue + pal = m.tile_palette(p['mat']) + register(p['tex'], p['tlut'], pal) + # An animated material can swap to any texture its channel names, so all + # of them have to be decoded up front. + if p['texAnim'] >= 0: + for a in auxAnims: + for t in unique(a.track(p['texAnim'])): + if t is not None: + register(t, p['tlut'], pal) + + prims = [] + for p in m.prims: + if not p['tris']: + continue + pos, uv, nrm, skin = [], [], [], [] + pal = m.tile_palette(p['mat']) + ti = texIndexMap.get((p['tex'], p['tlut'], pal), -1) + # texture-table index -> slot in texOut, for the animated swap + texMap = {} + if p['texAnim'] >= 0: + for a in auxAnims: + for t in unique(a.track(p['texAnim'])): + if t is not None and (t, p['tlut'], pal) in texIndexMap: + texMap[t] = texIndexMap[(t, p['tlut'], pal)] + tw, th = (m.textures[p['tex']]['w'], m.textures[p['tex']]['h']) if ti >= 0 else (32, 32) + for v in p['verts']: + pos += [v[0], v[1], v[2]] + uv += [v[3] / tw, v[4] / th] + nrm += [v[5] / 127.0, v[6] / 127.0, v[7] / 127.0] + skin.append(v[9]) + prims.append(dict(tex=ti, cull=p['cull'], texAnim=p['texAnim'], + texMap={str(k): v for k, v in sorted(texMap.items())}, + pos=pos, uv=uv, nrm=nrm, skin=skin, + idx=[i for t in p['tris'] for i in t])) + + def compress(values, nd): + """Constant tracks collapse to a scalar; most channels never move.""" + r = [round(v, nd) for v in values] + return r[0] if all(v == r[0] for v in r) else r + + anims = [] + for i, off in enumerate(m.anims): + a = Animation(f, off) + nf = max(1, a.nFrames) + tracks = [] + for b in m.bones: + ch = b['chan'] + bind = (b['t'], b['r'], b['s']) + if ch < 0 or a.sample_trs(ch, 0, bind) is None: + tracks.append(None) + continue + samples = [a.sample_trs(ch, fr, bind) for fr in range(nf)] + tracks.append(dict( + t=[compress([s[0][k] for s in samples], 3) for k in range(3)], + r=[compress([s[1][k] for s in samples], 0) for k in range(3)], + s=[compress([s[2][k] for s in samples], 5) for k in range(3)])) + anims.append(dict(index=i, frames=nf, flags=a.flags, + channels=a.nChannels, loopStart=a.loopStart, tracks=tracks)) + + auxOut = [] + for i, a in enumerate(auxAnims): + auxOut.append(dict(index=i, frames=max(1, a.nFrames), flags=a.flags, + loopStart=a.loopStart, + channels=[a.track(c) for c in range(a.nChannels)])) + + return dict( + species=m.species, + name=SPECIES.get(m.species, f'#{m.species}'), + file=os.path.basename(f.name), + rootScale=m.rootScale, + bones=[dict(parent=b['parent'], boneId=b['boneId'], chan=b['chan'], + flags=b['flags'], t=b['t'], r=b['r'], s=b['s']) for b in m.bones], + textures=texOut, + prims=prims, + anims=anims, + auxAnims=auxOut, + fx=dedupe_fx(m.fx), + warnings=m.warnings, + ) + + +SPECIES = {} +_NAMES = ( + "Bulbasaur Ivysaur Venusaur Charmander Charmeleon Charizard Squirtle Wartortle Blastoise " + "Caterpie Metapod Butterfree Weedle Kakuna Beedrill Pidgey Pidgeotto Pidgeot Rattata Raticate " + "Spearow Fearow Ekans Arbok Pikachu Raichu Sandshrew Sandslash NidoranF Nidorina Nidoqueen " + "NidoranM Nidorino Nidoking Clefairy Clefable Vulpix Ninetales Jigglypuff Wigglytuff Zubat " + "Golbat Oddish Gloom Vileplume Paras Parasect Venonat Venomoth Diglett Dugtrio Meowth Persian " + "Psyduck Golduck Mankey Primeape Growlithe Arcanine Poliwag Poliwhirl Poliwrath Abra Kadabra " + "Alakazam Machop Machoke Machamp Bellsprout Weepinbell Victreebel Tentacool Tentacruel Geodude " + "Graveler Golem Ponyta Rapidash Slowpoke Slowbro Magnemite Magneton Farfetchd Doduo Dodrio " + "Seel Dewgong Grimer Muk Shellder Cloyster Gastly Haunter Gengar Onix Drowzee Hypno Krabby " + "Kingler Voltorb Electrode Exeggcute Exeggutor Cubone Marowak Hitmonlee Hitmonchan Lickitung " + "Koffing Weezing Rhyhorn Rhydon Chansey Tangela Kangaskhan Horsea Seadra Goldeen Seaking " + "Staryu Starmie MrMime Scyther Jynx Electabuzz Magmar Pinsir Tauros Magikarp Gyarados Lapras " + "Ditto Eevee Vaporeon Jolteon Flareon Porygon Omanyte Omastar Kabuto Kabutops Aerodactyl " + "Snorlax Articuno Zapdos Moltres Dratini Dragonair Dragonite Mewtwo Mew").split() +for _i, _n in enumerate(_NAMES): + SPECIES[_i + 1] = _n + + +if __name__ == '__main__': + here = os.path.dirname(os.path.abspath(__file__)) + src = sys.argv[1] if len(sys.argv) > 1 else 'assets/us/pokemon_models/24.bin' + dst = sys.argv[2] if len(sys.argv) > 2 else os.path.join(here, 'model.js') + data = extract(src) + body = json.dumps(data, separators=(',', ':')) + with open(dst, 'w') as fp: + fp.write('window.PKMN_MODEL = ' + body + ';\n') + tris = sum(len(p['idx']) // 3 for p in data['prims']) + print(f"{data['name']} (#{data['species']}) bones={len(data['bones'])} prims={len(data['prims'])} " + f"tris={tris} textures={len(data['textures'])} anims={len(data['anims'])}") + print(f"frames per anim: {[a['frames'] for a in data['anims']]}") + if data['warnings']: + print('warnings:', data['warnings'][:5]) + print(f'wrote {dst} ({os.path.getsize(dst)/1024:.0f} KB)') diff --git a/model_extract/pipeline/glb.py b/model_extract/pipeline/glb.py new file mode 100644 index 0000000..ab83931 --- /dev/null +++ b/model_extract/pipeline/glb.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +""" +glTF 2.0 binary writer. + +Each game bone becomes two nodes -- a pivot carrying translation/rotation and a +leaf carrying the accumulated scale -- because the game keeps scale out of the +matrix chain while glTF propagates it to children. See ../README.md. +""" +import json +import math +import struct + +ND = 7 # decimals kept on node rest transforms + + +def quat_from_euler(r): + """The game's rotation is Rx*Ry*Rz in row-vector form (src/F420.c + func_8000F730); build that basis as glTF columns and convert.""" + sx, cx = math.sin(r[0] / 32768 * math.pi), math.cos(r[0] / 32768 * math.pi) + sy, cy = math.sin(r[1] / 32768 * math.pi), math.cos(r[1] / 32768 * math.pi) + sz, cz = math.sin(r[2] / 32768 * math.pi), math.cos(r[2] / 32768 * math.pi) + # rows of the game matrix become the columns of the glTF rotation + m = ((cy*cz, sx*sy*cz - cx*sz, cx*sy*cz + sx*sz), + (cy*sz, sx*sy*sz + cx*cz, cx*sy*sz - sx*cz), + (-sy, sx*cy, cx*cy)) + tr = m[0][0] + m[1][1] + m[2][2] + if tr > 0: + s = math.sqrt(tr + 1.0) * 2 + w = 0.25 * s + x = (m[2][1] - m[1][2]) / s + y = (m[0][2] - m[2][0]) / s + z = (m[1][0] - m[0][1]) / s + elif m[0][0] > m[1][1] and m[0][0] > m[2][2]: + s = math.sqrt(1.0 + m[0][0] - m[1][1] - m[2][2]) * 2 + w = (m[2][1] - m[1][2]) / s + x = 0.25 * s + y = (m[0][1] + m[1][0]) / s + z = (m[0][2] + m[2][0]) / s + elif m[1][1] > m[2][2]: + s = math.sqrt(1.0 + m[1][1] - m[0][0] - m[2][2]) * 2 + w = (m[0][2] - m[2][0]) / s + x = (m[0][1] + m[1][0]) / s + y = 0.25 * s + z = (m[1][2] + m[2][1]) / s + else: + s = math.sqrt(1.0 + m[2][2] - m[0][0] - m[1][1]) * 2 + w = (m[1][0] - m[0][1]) / s + x = (m[0][2] + m[2][0]) / s + y = (m[1][2] + m[2][1]) / s + z = 0.25 * s + n = math.sqrt(x*x + y*y + z*z + w*w) or 1.0 + return [x/n, y/n, z/n, w/n] + + +def pose(bones, sample_fn): + """Returns (pivotT, pivotQ, jointS) for every bone at one instant.""" + acc, pt, pq, js = [], [], [], [] + for i, b in enumerate(bones): + t, r, s = sample_fn(i, b) + pa = acc[b['parent']] if b['parent'] >= 0 else (1.0, 1.0, 1.0) + pt.append([t[0]*pa[0], t[1]*pa[1], t[2]*pa[2]]) + pq.append(quat_from_euler(r)) + a = (pa[0]*s[0], pa[1]*s[1], pa[2]*s[2]) + acc.append(a) + js.append(list(a)) + return pt, pq, js + + +# ------------------------------------------------------------------ glTF build + +class Glb: + def __init__(self): + self.buf = bytearray() + self.views = [] + self.accessors = [] + + def view(self, data, target=None): + while len(self.buf) % 4: + self.buf.append(0) + off = len(self.buf) + self.buf += data + v = dict(buffer=0, byteOffset=off, byteLength=len(data)) + if target: + v['target'] = target + self.views.append(v) + return len(self.views) - 1 + + def accessor(self, data, ctype, atype, count, target=None, + minmax=None, normalized=False): + a = dict(bufferView=self.view(data, target), componentType=ctype, + count=count, type=atype) + if normalized: + a['normalized'] = True + if minmax: + a['min'], a['max'] = minmax + self.accessors.append(a) + return len(self.accessors) - 1 + + def floats(self, values, atype, target=None, minmax=None): + n = {'SCALAR': 1, 'VEC2': 2, 'VEC3': 3, 'VEC4': 4, 'MAT4': 16}[atype] + return self.accessor(struct.pack(f'<{len(values)}f', *values), + 5126, atype, len(values) // n, target, minmax) + + def finish(self, gltf): + gltf['buffers'] = [dict(byteLength=len(self.buf))] + gltf['bufferViews'] = self.views + gltf['accessors'] = self.accessors + js = json.dumps(gltf, separators=(',', ':')).encode() + js += b' ' * (-len(js) % 4) + bin_ = bytes(self.buf) + b'\0' * (-len(self.buf) % 4) + return (struct.pack('= 0 else 0 + nodes[parent].setdefault('children', []).append(pivot_id[i]) + + # ---- textures / materials --------------------------------------------- + images, samplers, textures, materials = [], [], [], [] + if pngs: + samplers.append(dict(magFilter=9729, minFilter=9729, + wrapS=33071, wrapT=33071)) # LINEAR, CLAMP + for i, blob in enumerate(pngs): + images.append(dict(mimeType='image/png', + bufferView=g.view(blob), name=f'tex{i:02d}')) + textures.append(dict(sampler=0, source=i)) + + prims_out = [] + for p in data['prims']: + nv = len(p['pos']) // 3 + pos = [float(v) for v in p['pos']] + mn = [min(pos[k::3]) for k in range(3)] + mx = [max(pos[k::3]) for k in range(3)] + attrs = dict( + POSITION=g.floats(pos, 'VEC3', 34962, (mn, mx)), + NORMAL=g.floats([float(v) for v in p['nrm']], 'VEC3', 34962), + TEXCOORD_0=g.floats([float(v) for v in p['uv']], 'VEC2', 34962), + JOINTS_0=g.accessor( + struct.pack(f'<{nv*4}H', *[v for j in p['skin'] for v in (j, 0, 0, 0)]), + 5123, 'VEC4', nv, 34962), + WEIGHTS_0=g.floats([v for _ in range(nv) for v in (1.0, 0.0, 0.0, 0.0)], + 'VEC4', 34962), + ) + idx = g.accessor(struct.pack(f'<{len(p["idx"])}H', *p['idx']), + 5123, 'SCALAR', len(p['idx']), 34963) + blend = p.get('blend') + mat = dict( + name=f'mat{len(materials):02d}', + alphaMode='BLEND' if blend else 'MASK', + doubleSided=bool(blend) or not (p['cull'] & 0x400), + pbrMetallicRoughness=dict(metallicFactor=0.0, roughnessFactor=0.9), + ) + if blend: + # generated effects are unlit so they read as emissive fire/gas + mat['emissiveFactor'] = [1.0, 1.0, 1.0] + else: + mat['alphaCutoff'] = 0.5 + if p['tex'] >= 0: + mat['pbrMetallicRoughness']['baseColorTexture'] = dict(index=p['tex']) + if blend: + mat['emissiveTexture'] = dict(index=p['tex']) + materials.append(mat) + prims_out.append(dict(attributes=attrs, indices=idx, + material=len(materials) - 1)) + + skin_node = len(nodes) + nodes.append(dict(name=data['name'], mesh=0, skin=0)) + + ident = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] + gltf['skins'] = [dict(joints=joint_id, skeleton=0, + inverseBindMatrices=g.floats(ident * nb, 'MAT4'))] + gltf['meshes'] = [dict(name=data['name'], primitives=prims_out)] + + # ---- animations -------------------------------------------------------- + anims = [] + for a in data['anims']: + nf = a['frames'] + times = [round(fr / 30.0, 6) for fr in range(nf)] # authored at 30 fps + + def sample_fn(i, b, _a=a): + tr = _a['tracks'][i] + if not tr: + return b['t'], b['r'], b['s'] + pick = lambda c, fr: (c if isinstance(c, (int, float)) + else c[min(fr, len(c) - 1)]) + return ([pick(c, sample_fn.fr) for c in tr['t']], + [pick(c, sample_fn.fr) for c in tr['r']], + [pick(c, sample_fn.fr) for c in tr['s']]) + + seq_t = [[] for _ in range(nb)] + seq_q = [[] for _ in range(nb)] + seq_s = [[] for _ in range(nb)] + for fr in range(nf): + sample_fn.fr = fr + pt, pq, js = pose(bones, sample_fn) + for i in range(nb): + if seq_q[i] and sum(x*y for x, y in zip(seq_q[i][-1], pq[i])) < 0: + pq[i] = [-v for v in pq[i]] # keep quaternions continuous + seq_t[i].append(pt[i]); seq_q[i].append(pq[i]); seq_s[i].append(js[i]) + + channels, samplers_a = [], [] + cache = {} + + def time_accessor(keys): + if keys not in cache: + t = times if keys == nf else [times[0], times[-1]] + cache[keys] = g.floats(t, 'SCALAR', minmax=([t[0]], [t[-1]])) + return cache[keys] + + for i in range(nb): + for seq, path, node, dflt in ( + (seq_t[i], 'translation', pivot_id[i], nodes[pivot_id[i]]['translation']), + (seq_q[i], 'rotation', pivot_id[i], nodes[pivot_id[i]]['rotation']), + (seq_s[i], 'scale', joint_id[i], nodes[joint_id[i]]['scale'])): + const = all(v == seq[0] for v in seq) + # A constant channel can only be dropped when it already equals the + # node's rest value; otherwise the node would sit in its bind pose. + if const and [round(c, ND) for c in seq[0]] == dflt: + continue + if const: + seq = [seq[0], seq[0]] + time_acc = time_accessor(len(seq)) + flat = [c for v in seq for c in v] + if path == 'rotation': + out = g.accessor( + struct.pack(f'<{len(flat)}h', + *[max(-32768, min(32767, round(c * 32767))) + for c in flat]), + 5122, 'VEC4', len(seq), normalized=True) + else: + out = g.floats(flat, 'VEC3') + samplers_a.append(dict(input=time_acc, output=out, + interpolation='LINEAR')) + channels.append(dict(sampler=len(samplers_a) - 1, + target=dict(node=node, path=path))) + if channels: + anims.append(dict(name=a['name'], channels=channels, samplers=samplers_a)) + if anims: + gltf['animations'] = anims + + gltf['nodes'] = nodes + gltf['scenes'] = [dict(nodes=[0, skin_node])] + gltf['scene'] = 0 + if images: + gltf['images'] = images + gltf['samplers'] = samplers + gltf['textures'] = textures + gltf['materials'] = materials + return g.finish(gltf) + + diff --git a/model_extract/pipeline/rom.py b/model_extract/pipeline/rom.py new file mode 100644 index 0000000..404db6f --- /dev/null +++ b/model_extract/pipeline/rom.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +""" +Raw ROM access for the Pokemon Stadium (US) model export. + +Everything here is stdlib-only: byte-order fixup, the Yay0 decompressor, the +PERS-SZP wrapper the assets use, and the little archive format that packs many +files into one segment. That is all it takes to get from baserom.z64 to model +data, so the export does not need `make init`, splat or crunch64. +""" +import hashlib +import struct + +# ROM offsets taken from pokestadium-us.yaml. +POKEMON_MODELS = 0x920000 # archive of the 215 battle models +BATTLE_DATA = 0x70D3A0 # per-species battle tables, indexed by D_80075BD0 +MAIN_ROM = 0x1000 # main code segment ... +MAIN_VRAM = 0x80000400 # ... and where it lands in RAM +PTR_TABLE_VRAM = 0x80075BD0 # D_80075BD0[species - 1] -> offset into BATTLE_DATA + +US_MD5 = 'ed1378bc12115f71209a77844965ba50' + + +class Rom: + def __init__(self, path): + data = bytearray(open(path, 'rb').read()) + magic = struct.unpack_from('>I', data, 0)[0] + if magic == 0x37804012: # .v64, byte-swapped pairs + data[0::2], data[1::2] = data[1::2], data[0::2] + elif magic == 0x40123780: # .n64, word-reversed + data = bytearray(b''.join(data[i:i+4][::-1] for i in range(0, len(data), 4))) + elif magic != 0x80371240: # .z64, native big endian + raise ValueError(f'{path}: not an N64 ROM (magic {magic:#010x})') + self.data = bytes(data) + self.md5 = hashlib.md5(self.data).hexdigest() + + @property + def is_expected_us(self): + return self.md5 == US_MD5 + + def u32(self, o): + return struct.unpack_from('>I', self.data, o)[0] + + def vram_to_rom(self, vram): + return MAIN_ROM + (vram - MAIN_VRAM) + + # ---- archive --------------------------------------------------------- + def archive(self, off): + """Segments that hold many files start with + u32 tag, u32 0, u32 totalSize, u32 fileCount + followed by fileCount { u32 offset, u32 size, u32 pad[2] } records, + all relative to the start of the segment (tools/unpack_asset.py). + Only the top three bytes of the first word are reliably zero -- the + model archive puts a nonzero value in the low byte.""" + if (self.u32(off) & 0xFFFFFF00) != 0 or self.u32(off + 4) != 0: + return [self.data[off:]] + count = self.u32(off + 12) + if not 0 < count < 4096: + return [self.data[off:]] + out = [] + for i in range(count): + rec = off + 0x10 + i * 0x10 + start, size = self.u32(rec), self.u32(rec + 4) + out.append(self.data[off + start: off + start + size]) + return out + + +# ------------------------------------------------------------- decompression + +def yay0_decompress(src): + """Nintendo Yay0. Header: magic, decompressed size, link offset, chunk + offset; then a bitstream where a 1 copies one literal byte and a 0 pulls a + (distance, length) pair from the link table.""" + if src[:4] != b'Yay0': + raise ValueError('not Yay0') + size, link_off, chunk_off = struct.unpack_from('>3I', src, 4) + out = bytearray(size) + mask_p, link_p, chunk_p, pos = 0x10, link_off, chunk_off, 0 + mask, bits = 0, 0 + while pos < size: + if bits == 0: + mask = struct.unpack_from('>I', src, mask_p)[0] + mask_p += 4 + bits = 32 + if mask & 0x80000000: + out[pos] = src[chunk_p] + chunk_p += 1 + pos += 1 + else: + link = struct.unpack_from('>H', src, link_p)[0] + link_p += 2 + dist = link & 0x0FFF + count = link >> 12 + if count == 0: + count = src[chunk_p] + 0x12 + chunk_p += 1 + else: + count += 2 + copy = pos - dist - 1 + for _ in range(count): # overlapping runs are legal + out[pos] = out[copy] + pos += 1 + copy += 1 + mask = (mask << 1) & 0xFFFFFFFF + bits -= 1 + return bytes(out) + + +def decompress(blob): + """Unwrap whatever container an asset arrived in.""" + if blob[:8] == b'PERS-SZP': + header = struct.unpack_from('>I', blob, 8)[0] + return yay0_decompress(blob[header:]) + if blob[:4] == b'Yay0': + return yay0_decompress(blob) + return blob + + +def pokemon_models(rom): + """Returns the decompressed model fragments, indexed by file number.""" + return [decompress(b) for b in rom.archive(POKEMON_MODELS)] diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 6c3c2ed..4614afe 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -204,8 +204,19 @@ T.check(not pinnedIds["battleLayout"], T.eq(layoutGame.save.options.battleLayout, "og", "and a save that had WIDE is set to OG -- the only layout the shot composes in") +-- the STADIUM rung is still a staged fight, so it pins the layout exactly +-- as 2D-3D does: what changes on that rung is what stands on the cells, not +-- where the cells are or what screen they are composed for +Battles.setting:setValue("stadium", layoutGame) +T.eq(Battles.enabled(), true, "STADIUM stages the fight like 2D-3D does") +layoutGame.save.options.battleLayout = "wide" +Runtime.call("ui.options.rows", function(_, r) return r end, layoutGame, + { { id = "battleLayout" } }) +T.eq(layoutGame.save.options.battleLayout, "og", + "and pins BATTLE LAYOUT to OG the same way") + -- switching 3D-BTL off hands the row straight back, WIDE and all -Battles.setting:setIndex(2, layoutGame) +Battles.setting:setValue(false, layoutGame) T.eq(Battles.enabled(), false, "3D-BTL off") local handedBack = Runtime.call("ui.options.rows", function(_, r) return r end, layoutGame, @@ -235,7 +246,7 @@ T.eq(layoutGame.save.options.battleLayout, "wide", -- switch the row back on and the pin comes back with it, FULL or no FULL. -- (Arriving at FULL for real runs applyFull, which switches the row on -- so -- in the game the pin still follows the preset, by way of the row.) -Battles.setting:setIndex(1, layoutGame) +Battles.setting:setValue(true, layoutGame) Runtime.call("ui.options.rows", function(_, r) return r end, layoutGame, { { id = "battleLayout" } }) T.eq(layoutGame.save.options.battleLayout, "og", @@ -366,7 +377,7 @@ T.check(rowIndex(menu, "pipeline:tiltshift"), "T-SHIFT too") -- down than the player left it. do local Battles = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle") -Battles.setting:setIndex(2, menuGame) -- staged battles off +Battles.setting:setValue(false, menuGame) -- staged battles off menuGame.save.options.battleLayout = "wide" Pipelines.setLevel("voxel", 2) local layoutMenu = OptionsMenu.new(menuGame) @@ -389,7 +400,12 @@ end Pipelines.setLevel("voxel", 2) local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end, { data = Data }, { { id = "text_speed" } }) -T.eq(#hookedRows, 9, "the options hook added a row per setting") +-- one per setting, plus the STADIUM ROM action row -- which is not a setting +-- (nothing to store, nothing for the mod manager to persist) and is offered +-- on every platform, saying WHERE? rather than IMPORT where there is no file +-- dialog to open +T.eq(#hookedRows, 10, "the options hook added a row per setting, plus the " + .. "STADIUM ROM action row") local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[4] local battles, backRow, daytime = hookedRows[5], hookedRows[6], hookedRows[7] -- the AA row is hookedRows[8]; it is read in its own block below, because @@ -408,9 +424,10 @@ T.eq(grid.value(), "OFF", "the grid starts off") T.eq(curve.label, "V-CURVE", "the curve row carries its label") T.eq(curve.value(), "OFF", "the curve starts off") T.eq(battles.label, "3D-BTL", "the overworld-battle row carries its label") -T.eq(battles.value(), "ON", - "overworld battles are on by default -- the mode's headline is the world " - .. "in 3D, and a battle is where the player spends half the game") +T.eq(battles.value(), "2D-3D A", + "overworld battles are on by default, on the rung that stands the game's " + .. "own pics on the map -- the mode's headline is the world in 3D, and a " + .. "battle is where the player spends half the game") T.eq(backRow.label, "BACK SPRITES", "the back-pic row carries its label") T.eq(backRow.value(), "OFF", "and is off by default -- what the mode advertises is BOTH mons out on the " @@ -1124,11 +1141,553 @@ Game.keypressed(keyGame, "7") T.neq(Curve.setting:get(), curveBefore, "7 cycles V-CURVE") local Battles = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle") -T.eq(Battles.setting:get(), true, "3D-BTL starts on") + +-- The two STADIUM rungs are gated on the models being installed, and they are +-- not installed anywhere this suite runs: the repository carries no Pokemon +-- Stadium data, so a clean clone has none and a developer checkout has them +-- only after tools/stadium_pack.py has been run. What the walk below is about +-- is the LADDER -- five rungs, in order, wrapping -- so the gate is held OPEN +-- for it and the skipping behaviour is tested on its own further down, where +-- it is the subject rather than an accident of the machine it ran on. +-- +-- Held in GLOBALS rather than locals, here and for the pack probe below: this +-- chunk is at Lua's 200-local ceiling and three more would not compile. +BATTLE_ROW_GATE = Battles.setting.gate +Battles.setting:setGate(function() return true end) + +T.eq(Battles.setting:get(), true, "3D-BTL starts on 2D-3D A") +T.eq(Battles.discs(), false, "which stages the fight on the map") Game.keypressed(keyGame, "8") -T.eq(Battles.setting:get(), false, "8 toggles overworld battles off") +T.eq(Battles.setting:get(), "flatB", + "8 steps to 2D-3D B -- the same pics, on the discs") +T.eq(Battles.discs(), true, + "and that IS a disc rung, with no Stadium model anywhere in it") Game.keypressed(keyGame, "8") -T.eq(Battles.setting:get(), true, "and back on") +T.eq(Battles.setting:get(), "stadium", + "again and it is STADIUM A, the models on the map") +T.eq(Battles.discs(), false, "back on the map") +Game.keypressed(keyGame, "8") +T.eq(Battles.setting:get(), "stadiumB", + "again and it is STADIUM B, the models on the discs") +T.eq(Battles.discs(), true, "on the discs again") +Game.keypressed(keyGame, "8") +T.eq(Battles.setting:get(), false, "again and overworld battles are off") +Game.keypressed(keyGame, "8") +T.eq(Battles.setting:get(), true, "and the ladder wraps back to 2D-3D A") + +Battles.setting:setGate(BATTLE_ROW_GATE) + +-- ------- importing a ROM instead of being told where to put one +-- +-- The row is an ACTION, not a setting: it has no stored rung, so it is not in +-- SETTINGS and the mod manager's page does not carry it. What it shows is a +-- state, and what it does is open the host's file dialog -- which is why it +-- is absent where no dialog can be opened rather than being offered as a +-- button that does nothing. +;(function() + local Pick = run.loader.exports.DRAMATIC_SHAPE.lib.require("StadiumRomPick") + local Install = + run.loader.exports.DRAMATIC_SHAPE.lib.require("StadiumInstall") + + T.check(type(Pick.canDialog()) == "boolean", + "the picker reports whether this platform has a file dialog at all") + + -- The row is offered on EVERY platform. It used to be dropped where no + -- dialog could be opened, which on Android read as the feature being + -- missing rather than manual -- and the folder path it needed to show was + -- only ever written to a console a phone does not have. + local row = Pick.row() + T.check(row ~= nil, "the row is offered whatever the platform can do") + T.eq(row.label, "STADIUM ROM", "and says what it is for") + T.check(type(row.step) == "function", "and does something when pressed") + T.eq(row.value(), + Install.available() and "READY" or (Pick.canDialog() and "IMPORT" or "WHERE?"), + "reading READY once installed, IMPORT where a dialog can be opened, and " + .. "WHERE? where pressing it can only name the folder -- a row that said " + .. "IMPORT and then did not import would be the worse lie") + + -- and the drop folder it would name is an absolute path, which the note + -- screen has to be able to show in full + T.check(type(Install.romHint()) == "string" and #Install.romHint() > 0, + "there is a folder to name") + + -- A ROM that carries no models must be refused BEFORE anything is written. + -- An empty build otherwise completes with nothing attempted and therefore + -- nothing failed, and the marker gets written saying so -- which on a + -- machine that already had a set would uninstall it, because the marker is + -- the only thing that makes 151 files on disk count as installed. + T.eq(Install.beginFrom("", "empty"), false, + "an empty file is refused outright") + T.eq(select(2, Install.beginFrom(("\0"):rep(4096), "zeros")) ~= nil, true, + "and so is a file that is not a ROM, with a reason") + T.eq(Install.status.state ~= "building", true, + "and neither of those started a build") +end)() + +-- ------- the model set, when there is one +-- +-- Everything below that loads a .dsm needs a BUILT set, and the repository +-- deliberately carries none: the models are Pokemon Stadium's data, built out +-- of the player's own ROM at runtime (StadiumInstall) or by +-- tools/stadium_pack.py into assets/stadium/ in a developer checkout. So a +-- clean clone has nothing to read, and these say so and stand down rather +-- than failing for the absence of data that is absent on purpose. +-- +-- Announced rather than silent. A test that quietly evaporates when its +-- fixture is missing is a test that has stopped running and not told anyone, +-- which is worse than one that fails. +HAVE_STADIUM_PACKS = + run.loader.exports.DRAMATIC_SHAPE.lib.require("StadiumPack").available(25) +if not HAVE_STADIUM_PACKS then + print("SKIP stadium model tests -- no built .dsm set (run " + .. "tools/stadium_pack.py, or play once with a ROM in baseroms/)") +end + +-- ------- the eyes blink once a loop, not six times a second +-- +-- A texture animation is sampled at the SKELETAL animation's frame and HOLDS +-- its last entry past the end of its own stream, which is what the game's own +-- sampler does. Wrapping on the stream's length instead plays it over and +-- over: Rattata's standby loop is 40 frames and its blink is 5, so that came +-- out as six blinks a second. +-- +-- Driven through a stub rather than a real rig, because building one needs +-- meshes and there is no graphics context here -- and the rule under test is +-- pure index arithmetic that does not care. +;(function() + local Rig = run.loader.exports.DRAMATIC_SHAPE.lib.require("StadiumRig") + -- one prim on channel 0, whose stream values 6/7/8 map to textures 60/70/80 + local prim = { tex = 1, texAnim = 0, texMap = { [6] = 60, [7] = 70, + [8] = 80 } } + local model = { + prims = { prim }, + textures = { [1] = { w = 1, h = 1 }, [60] = { w = 1, h = 1 }, + [70] = { w = 1, h = 1 }, [80] = { w = 1, h = 1 } }, + -- Rattata's actual blink: open, half, closed, half, open + auxAnims = { { frames = 5, loopStart = 0, channels = { { 6, 8, 7, 8, 6 } } } }, + } + local part = { prim = prim } + local stub = setmetatable({ model = model, parts = { part } }, Rig) + + -- StadiumPack.image wants a real texture; what is asserted here is WHICH + -- index was chosen, so record it instead + local Pack = run.loader.exports.DRAMATIC_SHAPE.lib.require("StadiumPack") + local realImage = Pack.image + local picked + Pack.image = function(_, index) picked = index return nil end + + local function at(frame) + stub.frameAt = frame + stub:textures(1) + return picked + end + + T.eq(at(0), 60, "frame 0 of the blink is the open eye") + T.eq(at(1), 80, "frame 1 is half closed") + T.eq(at(2), 70, "frame 2 is shut") + T.eq(at(4), 60, "and frame 4 is open again -- one blink, five frames") + -- the whole point: frames 5..39 of the forty-frame idle are NOT a second + -- blink, they are the eye staying open + T.eq(at(5), 60, "frame 5, past the end of the blink, HOLDS the open eye") + T.eq(at(20), 60, "and so does frame 20") + T.eq(at(39), 60, "and frame 39, the last of the idle loop") + + Pack.image = realImage +end)() + +-- ------- the skeleton runs at 60, the textures step at 30 +-- +-- The animation streams carry one value per frame at 30 Hz, so replayed +-- honestly against a 60 Hz camera every pose holds for two frames and the +-- models visibly stutter against everything around them. StadiumRig blends +-- between consecutive frames instead -- but NOT across a snap, because these +-- are Euler triples and two triples that describe nearly the same rotation +-- can be nowhere near each other component by component. Walking from one to +-- the other is a bone flipping over inside a frame, which is exactly the +-- glitch a naive version of this shipped with. +-- +-- Driven on a REAL species, through a rig with no meshes (there is no +-- graphics context here, and pose() only touches the matrix arrays). +;(function() + if not HAVE_STADIUM_PACKS then return end + local lib = run.loader.exports.DRAMATIC_SHAPE.lib + local Pack, Rig = lib.require("StadiumPack"), lib.require("StadiumRig") + local model = Pack.load(25) -- Pikachu + local rig = setmetatable({ model = model, pivotM = {}, drawM = {}, + accX = {}, accY = {}, accZ = {}, parts = {} }, Rig) + local slot = model.ctx[Pack.SLOT.idle] + local anim = (slot ~= Pack.NONE) and (slot + 1) or nil + local frames = anim and model.anims[anim].frames or 0 + + -- every bone's world origin under the pose as it stands + local function origins() + local out = {} + for b = 1, model.boneCount do + local o = (b - 1) * 12 + out[b] = { rig.drawM[o + 4], rig.drawM[o + 8], rig.drawM[o + 12] } + end + return out + end + + T.check(frames > 8, "Pikachu's standby loop is long enough to sample") + + rig:pose(anim, 3, true) + local a = origins() + rig:pose(anim, 4, true) + local c = origins() + rig:pose(anim, 3.5, true) + local b = origins() + + T.eq(rig.frameAt, 3, + "half a frame in, the TEXTURE frame is still the whole one -- an eye is " + .. "open or it is shut and there is no halfway swap to draw") + + -- the bone that travels furthest between those two frames is the one with + -- something to say about the blend + local best, moved = nil, 0 + for i = 1, #a do + local d = ((c[i][1] - a[i][1]) ^ 2 + (c[i][2] - a[i][2]) ^ 2 + + (c[i][3] - a[i][3]) ^ 2) ^ 0.5 + if d > moved then best, moved = i, d end + end + T.check(moved > 0, "and some bone actually moves between frames 3 and 4") + + -- halfway is HALFWAY: within a twentieth of the step of the midpoint of the + -- two frames it sits between, on every axis. A blend that overshoots, or + -- that walks the long way round a wrapped angle, fails this by miles. + local slack = moved / 20 + for axis = 1, 3 do + local mid = (a[best][axis] + c[best][axis]) / 2 + T.check(math.abs(b[best][axis] - mid) <= slack, + ("the half-frame pose sits between its two frames on axis %d"):format(axis)) + end + + -- and a whole frame is the frame itself, untouched: the blend has to be + -- exactly nothing at k = 0, or every stepped caller (the blink probe, the + -- oracle diff) is reading a pose the pack does not contain + rig:pose(anim, 4, true) + local again = origins() + T.eq(again[best][1], c[best][1], + "and a whole frame is that frame exactly, with nothing blended into it") +end)() + +-- ------- growing out of the ball +-- +-- The engine sizes its flat pic in the Game Boy's three steps across the +-- twelve frames AFTER the ball has finished opening. The model runs its own +-- ramp instead, started when the poof begins: continuous, and overlapping the +-- ball rather than following it. +;(function() + if not HAVE_STADIUM_PACKS then return end + local lib = run.loader.exports.DRAMATIC_SHAPE.lib + local Mon = lib.require("StadiumMon") + + local mon = Mon.new("player") + T.eq(mon:growScale(), 1, "a Pokemon that is not arriving is full size") + T.eq(mon:beginGrow(), false, + "and one with no model cannot start growing -- there is nothing to size") + + mon.model = { height = 10, rootScale = 1 } + T.eq(mon:beginGrow(), true, "with a model, the arrival starts") + T.eq(mon:growScale(), 0, "from nothing at all") + T.eq(mon:beginGrow(), false, + "and starting again is refused -- the engine's own send-out seam fires a " + .. "third of a second later and must not restart the ramp") + + -- the curve: slow, then quick through the middle, then settling + local last, monotonic = -1, true + for i = 0, 10 do + mon.grow = i / 10 + local s = mon:growScale() + if s < last then monotonic = false end + last = s + end + T.check(monotonic, "the ramp never goes backwards") + mon.grow = 0.5 + T.eq(mon:growScale(), 0.5, "and is half size exactly half way through") + mon.grow = 0.25 + T.check(mon:growScale() < 0.25, + "slower than linear early, so the Pokemon is still small while the ball " + .. "is coming apart") + + -- and it ends, rather than sticking at 0.99 + mon.grow = nil + mon.dt = 0 + mon:beginGrow() + for _ = 1, 200 do mon:update(1 / 60) end + T.eq(mon.grow, nil, "the ramp finishes") + T.eq(mon:growScale(), 1, "at exactly full size") + T.eq(mon.grewOwn, true, + "and remembers it owned this arrival, so the engine's three-step ramp is " + .. "not consulted for it afterwards -- it reads 5/7 in the gap and shrank " + .. "the Pokemon back down at the very end of the grow") +end)() + +-- ------- the pack cache must not evict a Pokemon that is standing there +-- +-- The eviction order is keyed on LOADS, and a side only loads when its +-- species changes -- so a Pokemon that has been out for a few turns is the +-- least recently loaded thing in the cache. A fifth species entering the +-- battle evicted it and RELEASED ITS TEXTURES mid-fight, and the next draw +-- threw "Cannot use object after it has been released" from inside the scene +-- pass, which took both models off the screen for the rest of the battle. +;(function() + if not HAVE_STADIUM_PACKS then return end + local Pack = run.loader.exports.DRAMATIC_SHAPE.lib.require("StadiumPack") + local keep = Pack.KEEP + Pack.forget() + Pack.KEEP = 2 + + local held = Pack.load(1) + T.check(held ~= nil, "a model loads") + local slot = held.textures and held.textures[1] + T.check(slot ~= nil, "and carries at least one texture slot") + + -- more species than the cache holds, WITHOUT saying the first is in use + Pack.load(4) Pack.load(7) Pack.load(10) + T.eq(slot.image, nil, + "an evicted model's texture slot is CLEARED, not left holding a released " + .. "object -- a released Image is still truthy, so the corpse came back " + .. "out of image() and died at mesh:setTexture") + + -- and with `keep` said every frame, as the mode does, it is never evicted + Pack.forget() + local live = Pack.load(1) + for _, dex in ipairs({ 4, 7, 10, 13 }) do + Pack.keep(1) + Pack.load(dex) + end + T.eq(Pack.load(1), live, + "a species the mode keeps saying is on the field is still the same " + .. "cached model after four others have loaded past it") + + Pack.KEEP = keep + Pack.forget() +end)() + +-- ------- and an animation must not walk the Pokemon out of the shot +-- +-- Stadium's animations were authored for a camera that followed the Pokemon; +-- this one holds two fixed cells. 65 of the 148 send-out entrances travel +-- more than a body-height off the spot, up to seven and a half -- which is +-- not drama here, it is an empty tile. +;(function() + if not HAVE_STADIUM_PACKS then return end + local lib = run.loader.exports.DRAMATIC_SHAPE.lib + local Pack, Rig, Mon = lib.require("StadiumPack"), lib.require("StadiumRig"), + lib.require("StadiumMon") + local model = Pack.load(87) -- Dewgong, the worst of them + local rig = setmetatable({ model = model, pivotM = {}, drawM = {}, + accX = {}, accY = {}, accZ = {}, parts = {} }, Rig) + rig:measureBind() + + -- the same quantity StadiumRig.anchor corrects -- bone origins averaged and + -- weighted by the vertices each bone moves -- written out here rather than + -- called, so this checks the behaviour and not its own arithmetic + local weight, total = {}, 0 + for b = 1, model.boneCount do weight[b] = 0 end + for _, prim in ipairs(model.prims) do + for k = 1, prim.vertCount do + local b = prim.bone[k] + if weight[b] then weight[b] = weight[b] + 1; total = total + 1 end + end + end + local function centre() + local x, y, z = 0, 0, 0 + for b = 1, model.boneCount do + local q = weight[b] + if q > 0 then + local o = (b - 1) * 12 + x = x + rig.drawM[o + 4] * q + y = y + rig.drawM[o + 8] * q + z = z + rig.drawM[o + 12] * q + end + end + return x / total, y / total, z / total + end + local raw = model.height / (model.rootScale > 0 and model.rootScale or 1) + local slot = model.ctx[Pack.SLOT.entrance] + local anim = slot + 1 + + local function driftAt(frame, limit) + rig:pose(anim, frame, false) + rig:anchor(limit) + local x, y, z = centre() + return (((x - model.bindCX) ^ 2 + (y - model.bindCY) ^ 2 + + (z - model.bindCZ) ^ 2) ^ 0.5) / raw + end + + T.check(driftAt(40, nil) > 5, + "unanchored, Dewgong's entrance carries it more than five body-heights " + .. "off its tile -- straight out of a frame that holds about one") + T.check(driftAt(40, Mon.TRAVEL) <= Mon.TRAVEL * 1.001, + "anchored, it stays inside the travel limit") + -- and the animations that never travel are left completely alone + local before = driftAt(0, nil) + T.eq(driftAt(0, Mon.TRAVEL), before, + "a frame already inside the limit is not moved at all -- the anchor takes " + .. "out the EXCESS, so a lunge is still a lunge") +end)() + +-- ------- the three species the extraction cannot read stand as PICS +-- +-- Exeggutor, Tangela and Magmar come out of the ROM with standby loops that +-- throw bones off the body; the packer measures that and flags them. They +-- used to hold their bind pose for the whole fight, which among a hundred and +-- forty-eight species that breathe reads as broken rather than as still. So +-- they decline the model outright and the Game Boy's own battle pic stands +-- instead -- the same fallback a species with no pack at all takes. +;(function() + if not HAVE_STADIUM_PACKS then return end + local lib = run.loader.exports.DRAMATIC_SHAPE.lib + local Mon = lib.require("StadiumMon") + for _, dex in ipairs({ 103, 114, 126 }) do + local mon = Mon.new("enemy") + T.eq(mon:setSpecies(dex), false, + ("dex %d has corrupt animation data, so no model stands for it") + :format(dex)) + T.eq(mon.rig, nil, "and there is no rig left behind to draw") + end +end)() + +-- ------- the collapse waits for the HP bar +-- +-- onFaint fires the moment HP reaches zero, but the engine queues the visible +-- collapse behind the move animation and the bar drain -- seconds later. The +-- model has to wait for the same thing, or a Pokemon lies down while its own +-- health is still draining above it. `shownHP` is the engine's own bar +-- position, so this is the bar and not a guess at how long it takes. +;(function() + local Stad = run.loader.exports.DRAMATIC_SHAPE.lib.require("Stadium") + local ready, due = Stad._faintReady, Stad._faintStillDue + + T.eq(ready({ shownHP = 19, mon = { hp = 0 } }), false, + "a battler at 0 HP whose bar still reads 19 is NOT ready to collapse") + T.eq(ready({ shownHP = 1, mon = { hp = 0 } }), false, + "nor at one point left on the bar") + T.eq(ready({ shownHP = 0, mon = { hp = 0 } }), true, + "and is the moment the bar reaches zero") + T.eq(ready({ mon = { hp = 0 } }), true, + "a battler with no bar to drain collapses at once, rather than never") + T.eq(ready(nil), false, "and a battler that is gone is not ready") + + T.eq(due({ faintQueued = true, mon = { hp = 0 } }), true, + "a queued faint at 0 HP is still owed") + T.eq(due({ faintQueued = true, mon = { hp = 12 } }), false, + "one that has been healed since is not -- the debt is dropped, not paid " + .. "late at whoever is standing there") + T.eq(due({ mon = { hp = 0 } }), false, "and an unqueued battler owes nothing") +end)() + +-- ------- and it gets to FINISH +-- +-- The engine takes a fainted pic off the field when its slide ends, which is +-- fourteen frames of a 60 Hz clock -- under a quarter of a second. The +-- shortest faint animation in the Stadium set is 49 frames of a 30 Hz one, +-- the median 110 and the longest 230, so held to the pic's window every model +-- was cut off inside the first fifth of its own collapse. It now stays until +-- the animation is done, and not one frame past that. +;(function() + local Stad = run.loader.exports.DRAMATIC_SHAPE.lib.require("Stadium") + local onField = Stad._onField + + -- the engine's own guards, mirrored in the shape onField reads them + local battle = { fxHidden = function() return false end, + fxFaintActive = function(_, b) return b.sliding end } + local function mon(state, done) + return { state = state, finished = function() return done end } + end + + battle.enemy = { sprite = true } + T.eq(onField(battle, "enemy", mon("idle", false)), true, + "a Pokemon that is standing there is on the field") + + battle.enemy = { sprite = true, fainted = true, sliding = true } + T.eq(onField(battle, "enemy", mon("faint", false)), true, + "and one whose pic is still sliding is too, as it always was") + + battle.enemy = { sprite = true, fainted = true, sliding = false } + T.eq(onField(battle, "enemy", mon("faint", false)), true, + "the pic has gone but the model has not finished falling -- it STAYS, " + .. "which is the whole of the fix: a quarter-second window was cutting " + .. "off animations that run for one to eight seconds") + T.eq(onField(battle, "enemy", mon("faint", true)), false, + "and the frame its collapse finishes, it goes -- nothing is left lying " + .. "on the field for the rest of the fight") + T.eq(onField(battle, "enemy", mon("idle", false)), false, + "a fainted battler whose model never got as far as the faint goes with " + .. "the pic, exactly as before") + T.eq(onField(battle, "enemy", nil), false, + "and a side with no model at all is not held open by this") + + -- ------- FLY and DIG take it off the field entirely + -- + -- The charge turn runs a 19-24 frame slide and ends by setting + -- `picFx[battler].hidden`; the release turn clears it. That field is the + -- engine's whole answer to "is this Pokemon on screen", and it is NOT + -- fxHidden, which is the damage blink alone -- so a model reading only the + -- blink stood on its tile while every attack aimed at it missed. + battle.enemy = { sprite = true } + battle.picFx = { [battle.enemy] = { hidden = true } } + T.eq(onField(battle, "enemy", mon("attack", false)), false, + "a Pokemon that has flown up or dug in is not on the field, however " + .. "much of its own animation is still to play") + + battle.picFx = { [battle.enemy] = { kind = "slideOff", t = 4 } } + T.eq(onField(battle, "enemy", mon("attack", false)), true, + "but it IS while the engine's slide is still running -- which is the " + .. "window its own launch animation plays in") + + battle.picFx = { [battle.enemy] = {} } + T.eq(onField(battle, "enemy", mon("idle", false)), true, + "and a pic program that has finished and cleared leaves it standing") + battle.picFx = nil +end)() + +-- ------- and the two STADIUM rungs are SKIPPED when the models are not there +-- +-- The mod ships no Pokemon Stadium data, so on a machine whose owner has not +-- supplied that ROM the row has three stops rather than five. Checked by +-- gating them off by hand rather than by hiding the packs, because what is +-- being tested is the ladder's behaviour and not the installer's. +-- +-- 2D-3D B survives that, which is the point of it being its own rung: the +-- discs are generated in Lua and the Pokemon on them are the game's own art, +-- so the disc framing is available to a player who has no Stadium ROM at all. +;(function() + local gate = Battles.setting.gate + Battles.setting:setGate(function(value) + return value ~= "stadium" and value ~= "stadiumB" + end) + T.eq(Battles.setting:rungs(), 3, + "with no models built the 3D-BTL row offers three rungs, not five") + Battles.setting:setValue(true, Game) + Game.keypressed(keyGame, "8") + T.eq(Battles.setting:get(), "flatB", + "8 still reaches 2D-3D B, which needs no ROM") + Game.keypressed(keyGame, "8") + T.eq(Battles.setting:get(), false, + "and the next press steps straight past both STADIUM rungs to OFF") + Game.keypressed(keyGame, "8") + T.eq(Battles.setting:get(), true, "and back to 2D-3D A") + + -- a save that CHOSE stadium before the ROM went missing reads as the + -- default, rather than as a mode with nothing behind it -- and the stored + -- value is left alone, so putting the ROM back restores the choice + Battles.setting.index = 3 + T.eq(Battles.setting:get(), true, + "a stored STADIUM with no models behind it reads as 2D-3D A") + -- and opening the gate hands the choice straight back, off the stored + -- value that was never overwritten. Opened by HAND rather than by putting + -- the real gate back: the real one answers "are the models installed on + -- this machine", which is false wherever this suite runs from a clean + -- clone -- and the subject here is the ladder, not the installer. + Battles.setting:setGate(function() return true end) + T.eq(Battles.setting:get(), "stadium", + "and comes back the moment the models do") + Battles.setting:setGate(gate) + Battles.setting:setValue(true, Game) +end)() -- ------- SELECT makes the same step the 3 key does -- @@ -2579,7 +3138,7 @@ T.eq(Battles.backPinned(), false, "so nothing is pinned to the menu") local backGame = { save = { options = { modOptions = {} } }, mods = { modOptions = {} } } -Battles.setting:setIndex(1, backGame) -- 3D-BTL on +Battles.setting:setValue(true, backGame) -- 3D-BTL on 2D-3D Battles.backSetting:setIndex(2, backGame) -- BACK SPRITES on T.eq(Battles.backPinned(), true, "switched on, the back pic is pinned") T.eq(backGame.save.options.modOptions.DRAMATIC_SHAPE.battleBack, true, @@ -2590,7 +3149,7 @@ T.eq(backGame.save.options.modOptions.DRAMATIC_SHAPE.battles, true, -- and it means nothing at all with staged battles off: there is no staged -- shot for a back pic to be pinned in front of, and the engine's own battle -- screen already draws exactly this -Battles.setting:setIndex(2, backGame) +Battles.setting:setValue(false, backGame) T.eq(Battles.backPinned(), false, "with 3D-BTL off the setting decides nothing, whatever it is left at") T.eq(Battles.backSetting:get(), true, "without being rewritten underneath") @@ -2606,7 +3165,7 @@ T.check(offIds["DRAMATIC_SHAPE:battles"], "3D-BTL itself is still offered") T.check(not offIds["DRAMATIC_SHAPE:battleBack"], "but BACK SPRITES is off the menu while there is no staged fight to be about") -Battles.setting:setIndex(1, backGame) +Battles.setting:setValue(true, backGame) local onRows = Runtime.call("ui.options.rows", function(_, r) return r end, backGame, { { id = "tilt" } }) local onAt = {} @@ -4929,6 +5488,106 @@ end)() T.eq(Gun.visible(), false, "and it is not drawn with the mode off") end)() +-- ------- STADIUM: the .dsm packs, and the rig that reads them +-- +-- Wrapped in its own scope for the same reason the horde block above is: +-- the main chunk is at Lua's 200-local ceiling, so a new top-level local +-- would refuse to compile. +-- +-- What this is really guarding is the FORMAT SEAM. tools/stadium_pack.py +-- writes those files and lib/StadiumPack.lua reads them, and the two agree +-- only by having been written to agree -- there is no schema between them. +-- A field inserted on one side and not the other slides every byte after it +-- and produces no error at all: the models load, the numbers are garbage, +-- and every Pokemon is silently scaled to nothing. That is exactly what +-- happened once during development, and the assertion that caught it is the +-- one below -- walk the bind pose with the REAL rig code and check it +-- against the header the packer wrote, which cannot agree by accident. +;(function() +local Pack = run.loader.exports.DRAMATIC_SHAPE.lib.require("StadiumPack") +local Rig = run.loader.exports.DRAMATIC_SHAPE.lib.require("StadiumRig") + +-- The packs are generated (tools/stadium_pack.py) and a checkout without +-- them is a legitimate state -- the mode declines per Pokemon. So the whole +-- block is skipped rather than failed when they are not there. +if not Pack.available(25) then + T.check(true, "stadium packs are not installed -- pack assertions skipped") + return +end + +local pikachu = Pack.load(25) +T.check(pikachu ~= nil, "a stadium pack loads") +T.eq(pikachu.species, 25, "and knows which species it is") +T.eq(pikachu.boneCount, 37, "Pikachu's rig is 37 bones, as the extract reports") +T.check(pikachu.rootScale > 0.09 and pikachu.rootScale < 0.11, + "the model_root scale came through as the 0.1 the geo layout sets") + +-- the battle system's own slot table: idle is animation 0 for all 151 +-- species (manifest.json's animationSlots calls that one `code` evidence) +T.eq(pikachu.ctx[Pack.SLOT.idle], 0, "the idle slot resolves to animation 0") +T.check(pikachu.ctx[Pack.SLOT.faint] ~= Pack.NONE, "and the faint slot resolves") +T.check(pikachu.ctx[Pack.SLOT.entrance] ~= Pack.NONE, "and the entrance slot") + +-- the move table is the Gen 1 move id, which is the engine's own `index` +T.eq(#pikachu.moveAnim, Pack.N_MOVES, "every move id has a row") +T.check(pikachu.moveAnim[85] ~= Pack.NONE, + "and THUNDERBOLT (move 85) names an animation Pikachu has") + +-- animations decode lazily; asking for one is what builds its tracks +local tracks = Pack.tracks(pikachu, 1) +T.check(type(tracks) == "table", "an animation's tracks decode on demand") +local animated = 0 +for b = 1, pikachu.boneCount do if tracks[b] then animated = animated + 1 end end +T.check(animated > 0 and animated <= pikachu.boneCount, + "and move a sane number of the rig's bones") + +-- THE SEAM. Walk the bind pose with the shipping rig code and measure it +-- the way tools/stadium_pack.py measured it. The packer's own answer was +-- checked against the reference glTF export on all 151 species, so +-- agreement here means the byte layout, the bone tree, the rotation basis +-- and the two-chain scale split all survived the trip into Lua. +local function bindExtent(model) + local rig = setmetatable({ + model = model, pivotM = {}, drawM = {}, accX = {}, accY = {}, accZ = {}, + parts = {}, + }, Rig) + rig:pose(nil, 0, false) + local drw = rig.drawM + local lo, hi = math.huge, -math.huge + for _, prim in ipairs(model.prims) do + for k = 1, prim.vertCount do + local o = (prim.bone[k] - 1) * 12 + local y = drw[o + 5] * prim.px[k] + drw[o + 6] * prim.py[k] + + drw[o + 7] * prim.pz[k] + drw[o + 8] + y = y * model.rootScale + if y < lo then lo = y end + if y > hi then hi = y end + end + end + return hi - lo, lo +end + +for _, dex in ipairs({ 25, 6, 95, 143 }) do + local model = Pack.load(dex) + if model then + local h, f = bindExtent(model) + -- a tenth of a game unit of slack: bone translations are stored as + -- integers and the packer measured in doubles + T.check(math.abs(h - model.height) < 0.5, + ("species %d: the rig's bind pose is the height the pack recorded " + .. "(walked %.2f, header %.2f)"):format(dex, h, model.height)) + T.check(math.abs(f - model.floor) < 0.5, + ("species %d: and its feet are where the pack put them"):format(dex)) + end +end + +-- the three species whose standby loop is corrupt in the source extraction +-- are marked to hold their bind pose instead of coming apart +T.eq(Pack.load(126).staticPose, true, + "Magmar is held at its bind pose -- its source animations are broken") +T.eq(pikachu.staticPose, false, "and a species with good data is not") +end)() + Pipelines.reset() run.release() diff --git a/tests/stadium_anim_qa.lua b/tests/stadium_anim_qa.lua new file mode 100644 index 0000000..3a4aac2 --- /dev/null +++ b/tests/stadium_anim_qa.lua @@ -0,0 +1,622 @@ +-- STADIUM battles: every Pokemon, every animation, every frame. +-- +-- luajit mods/DramaticShapeVoxelMod/tests/stadium_anim_qa.lua \ +-- [--packs=DIR] [--dex=N[,N...]] [--step=0.5] [--quiet] +-- +-- Run from the PROJECT ROOT. +-- +-- ------- what this is for +-- +-- A battle asks a species for one of its animations and then poses, skins, +-- re-textures and draws it sixty times a second. Nothing in that chain is +-- exercised by the pack probe, which reads the format and walks a bind pose, +-- and nothing in it is exercised by the shot drivers, which show one species +-- in one animation at a time. So the failures that only some species have -- +-- a texture index nothing maps, an animation that throws a bone into orbit, +-- a track that indexes off its own end -- have had no way of being found +-- except by a player calling that Pokemon out. +-- +-- This is that sweep, headless: the REAL StadiumPack, the REAL StadiumRig +-- and the REAL StadiumMon over stubs for the three things a graphics context +-- provides (a mesh, an image, the draw call). Every species, every animation +-- it carries, every frame of it, plus the battle state machine over every +-- one of the 165 move slots. +-- +-- ------- the stubs are not lenient +-- +-- The fake mesh and the fake image behave like LOVE's do in the one way that +-- matters: an object that has been released THROWS when it is used, with +-- LOVE's own message. That is deliberate -- a released texture reaching a +-- draw call is a real failure mode of this mode (see the LRU note in +-- StadiumPack), and a stub that quietly accepted one would hide exactly the +-- class of bug this sweep exists to find. + +local args = {} +local only = nil +for _, a in ipairs({ ... }) do + local k, v = a:match("^%-%-([%w_]+)=(.*)$") + if k then args[k] = v elseif a == "--quiet" then args.quiet = "1" end +end +if args.dex then + only = {} + for n in args.dex:gmatch("%d+") do only[#only + 1] = tonumber(n) end +end + +local MOD = "mods/DramaticShapeVoxelMod" + +-- Where the .dsm packs are. The mod builds them into LOVE's save directory +-- on first run (StadiumInstall), which is where a developer machine actually +-- has them; a checkout that has run tools/stadium_pack.py has them in the +-- mod instead. Both are tried, and --packs overrides. +local PACK_DIRS = {} +local function lookIn(dir) + if dir and dir ~= "" then PACK_DIRS[#PACK_DIRS + 1] = dir end +end +lookIn(args.packs) +lookIn(os.getenv("APPDATA") + and os.getenv("APPDATA") .. "/LOVE/pokemon-love2d/dramatic_shape/stadium") +lookIn(os.getenv("HOME") + and os.getenv("HOME") + .. "/.local/share/love/pokemon-love2d/dramatic_shape/stadium") +lookIn(MOD .. "/assets/stadium") + +-- How far apart the sampled frames are, in the animation's own 30 Hz frames. +-- A half frame rather than a whole one because the rig INTERPOLATES between +-- entries, and the blend has guards of its own that only run when k > 0 -- +-- sampling on whole frames alone would never execute them. +local STEP = tonumber(args.step or "0.5") + +-- How much taller than its own bind pose a posed model may stand before it +-- is called broken. Generous on purpose: a Pokemon that rears up, a wing +-- that opens, a Gyarados that lunges are all genuinely several times their +-- standing height. What this catches is the other thing -- a bone thrown +-- hundreds of units off the body, which comes out in the dozens. +local EXPLODE = 6.0 + +-- The texture index of a primitive the source says has no texture. The pack +-- stores indices one-based, so the packer's 0xFFFF sentinel arrives as this. +local UNTEXTURED = 0xFFFF + 1 + +-- species -> true, for the ones that carry such a primitive. Counted rather +-- than reported (see the texture check). +local untextured = {} + +-- species the rig refuses to anchor because their own standby loop says the +-- body estimate cannot be trusted (StadiumRig.ANCHOR_STEADY) +local unanchored = {} + +-- How far the mode lets an animation carry the Pokemon off its tile, in body +-- heights. Read from StadiumMon rather than repeated, so the sweep cannot go +-- on passing against a number the mode has since changed. +local TRAVEL = nil + +-- ------- the harness + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local packDir = nil +for _, dir in ipairs(PACK_DIRS) do + if dir and dir ~= "" then + local fp = io.open(dir .. "/001.dsm", "rb") + if fp then fp:close() packDir = dir break end + end +end +if not packDir then + print("no .dsm packs found -- pass --packs=DIR") + print("looked in:") + for _, dir in ipairs(PACK_DIRS) do + if dir and dir ~= "" then print(" " .. dir) end + end + os.exit(1) +end + +local function readFile(path) + local fp = io.open(path, "rb") + if not fp then return nil end + local bytes = fp:read("*a") + fp:close() + return bytes +end + +-- ------- the three things a graphics context provides +-- +-- A released object throws, exactly as LOVE's does. See the header. + +local RELEASED = "Cannot use object after it has been released." + +local function checkLive(obj) + if obj and obj.released then error(RELEASED, 0) end +end + +local function newFakeImage(w, h) + local img = { w = w, h = h, released = false } + function img:setFilter() checkLive(self) end + function img:setWrap() checkLive(self) end + function img:release() self.released = true return true end + function img:type() return "Image" end + return img +end + +local function newFakeMesh(format, rows) + local mesh = { released = false, verts = rows } + function mesh:setVertexMap() checkLive(self) end + function mesh:setVertices(v) checkLive(self) self.verts = v end + function mesh:setTexture(t) checkLive(self) checkLive(t) self.tex = t end + function mesh:release() self.released = true return true end + function mesh:type() return "Mesh" end + return mesh +end + +love = { + filesystem = { + getInfo = function(rel) + -- the mod asks for "dramatic_shape/stadium/NNN.dsm" relative to the + -- save directory; the sweep serves that out of packDir + local name = rel:match("([^/]+)$") + local fp = io.open(packDir .. "/" .. name, "rb") + if not fp then return nil end + fp:close() + return { type = "file" } + end, + read = function(rel) + local name = rel:match("([^/]+)$") + return readFile(packDir .. "/" .. name) + end, + }, + graphics = { + newMesh = function(format, rows, mode, usage) return newFakeMesh(format, rows) end, + newImage = function(data) return newFakeImage(data.w, data.h) end, + }, + image = { + newImageData = function(w, h, fmt, bytes) return { w = w, h = h } end, + }, +} + +local V = {} +local modules = {} +V.mod = { + log = { + warn = function(_, fmt, ...) print("[warn] " .. fmt:format(...)) end, + info = function(_, fmt, ...) print("[info] " .. fmt:format(...)) end, + }, + read = function(_, rel) return readFile(MOD .. "/" .. rel) end, +} +function V.require(name) + if modules[name] then return modules[name] end + local chunk = assert(loadfile(MOD .. "/lib/" .. name .. ".lua")) + modules[name] = chunk(V) + return modules[name] +end + +-- Voxel3D, to the extent the rig touches it. `draw` mirrors the real one's +-- first act -- binding the texture to the mesh -- because that is the line a +-- released texture dies on (Voxel3D.draw), and the whole point of the stub +-- is that it dies there here too. +local drawn, skipped = 0, 0 +modules.Voxel3D = { + FORMAT = {}, + seams = function() end, + glass = function() end, + blend = function() end, + draw = function(mesh, texture, model, pull, sunModel) + if not mesh then return end + if texture then mesh:setTexture(texture) end + drawn = drawn + 1 + end, +} +-- the shadow pass, same shape +local shadowMap = { draw = function(mesh, texture, model) + if texture then mesh:setTexture(texture) end +end } + +local Pack = V.require("StadiumPack") +local Rig = V.require("StadiumRig") +local Mon = V.require("StadiumMon") +TRAVEL = Mon.TRAVEL + +-- ------- findings + +local findings = {} -- kind -> { {dex=, anim=, frame=, detail=} ... } +local kinds = {} -- kind order, first seen first + +local function report(kind, dex, anim, frame, detail) + if not findings[kind] then + findings[kind] = {} + kinds[#kinds + 1] = kind + end + local list = findings[kind] + list[#list + 1] = { dex = dex, anim = anim, frame = frame, detail = detail } +end + +-- ------- one animation, frame by frame + +-- Where the BODY of a posed rig is: the bone origins averaged, weighted by +-- how many vertices each bone moves. +-- +-- The same QUANTITY StadiumRig.anchor corrects, written out a second time +-- rather than called: a check that shared the code it is checking would agree +-- with it by construction. It must be the same quantity, though -- an earlier +-- version of this measured the MEDIAN instead, and reported 27,965 findings +-- purely because it was asking a different question than the anchor answers. +local function bodyCentreOf(rig) + local m = rig.model + local w, total = m.qaWeights, m.qaWeightTotal + if not w then + w, total = {}, 0 + for b = 1, m.boneCount do w[b] = 0 end + for _, prim in ipairs(m.prims) do + for k = 1, prim.vertCount do + local b = prim.bone[k] + if w[b] then w[b] = w[b] + 1; total = total + 1 end + end + end + m.qaWeights, m.qaWeightTotal = w, total + end + if not (total > 0) then return 0, 0, 0 end + local x, y, z, d = 0, 0, 0, rig.drawM + for b = 1, m.boneCount do + local q = w[b] + if q > 0 then + local o = (b - 1) * 12 + x = x + d[o + 4] * q + y = y + d[o + 8] * q + z = z + d[o + 12] * q + end + end + return x / total, y / total, z / total +end + +local function bboxOf(rig) + local lo, hi = math.huge, -math.huge + local bad = false + for _, part in ipairs(rig.parts) do + local rows = part.rows + for k = 1, part.prim.vertCount do + local row = rows[k] + local x, y, z = row[1], row[2], row[3] + -- NaN is the only value not equal to itself; infinity survives that + -- test and has to be asked about separately + if x ~= x or y ~= y or z ~= z + or x == math.huge or x == -math.huge + or y == math.huge or y == -math.huge + or z == math.huge or z == -math.huge then + bad = true + else + if y < lo then lo = y end + if y > hi then hi = y end + end + end + end + if lo > hi then return 0, bad end + return hi - lo, bad +end + +-- A species' worth of work. Returns the number of frames stepped. +local function sweepSpecies(dex) + local model = Pack.load(dex) + if not model then + -- say WHY, or a sweep that ran out of file handles reads as 108 broken + -- Pokemon + local _, err = io.open(("%s/%03d.dsm"):format(packDir, dex), "rb") + report("pack did not load", dex, nil, nil, + ("StadiumPack.load returned nil (io.open says: %s)") + :format(tostring(err))) + return 0 + end + + local rig = Rig.new(model) + if not rig then + report("rig would not build", dex, nil, nil, + ("%d prims, %d bones"):format(model.primCount, model.boneCount)) + return 0 + end + + -- the bind pose, as the yardstick every posed frame is measured against + rig:measureBind() + rig:pose(nil, 0, false) + rig:skin(0) + local bind = bboxOf(rig) + if not (bind > 0) then bind = 1 end + -- and where the bind pose puts the BODY, for the travel check below. The + -- tracks are in raw units, before the model_root scale model.height is + -- measured after. + if model.anchorOk == false then unanchored[dex] = true end + local bcx, bcy, bcz = bodyCentreOf(rig) + local rawHeight = (model.height or 0) + / ((model.rootScale or 0) > 0 and model.rootScale or 1) + if not (rawHeight > 0) then rawHeight = 1 end + + local steps = 0 + for index, anim in ipairs(model.anims) do + local name = anim.name or ("#" .. index) + local frames = anim.frames or 1 + if frames < 1 then + report("animation has no frames", dex, name, nil, + ("frames=%d"):format(frames)) + end + -- the loop start has to be inside the animation or the wrap arithmetic + -- lands outside the track arrays + local loop = anim.loopStart or 0 + if loop < 0 or loop >= frames then + report("loopStart out of range", dex, name, nil, + ("loopStart=%d of %d frames"):format(loop, frames)) + end + if anim.aux and not (model.auxAnims and model.auxAnims[anim.aux]) then + report("aux animation missing", dex, name, nil, + ("anim.aux=%s, %d aux animations") + :format(tostring(anim.aux), model.auxCount or 0)) + end + + -- Both ways round: a standby loop WRAPS at the far end and a faint HOLDS + -- on its last frame, and the two take different branches of the pose + -- walk. Sampled a little past the end on purpose -- that is where a + -- battle actually leaves them. + for _, wrap in ipairs({ true, false }) do + local f = 0 + while f < frames + 2 do + local ok, err = pcall(function() + rig:pose(index, f, wrap) + rig:skin(0.5) + rig:textures(anim.aux) + rig:draw({ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }, 0) + end) + steps = steps + 1 + if not ok then + report("threw while playing", dex, name, f, tostring(err)) + break + end + -- ------- does it stay on its tile? + -- + -- Stadium's animations were authored for a camera that FOLLOWED the + -- Pokemon around a stage; this mode's camera is solved to hold two + -- fixed map cells and does not move. So an animation that walks the + -- body several of its own heights away does not look dramatic here, + -- it looks like the Pokemon is missing -- which is what sending out + -- a Farfetch'd did for three and a half seconds. + -- + -- StadiumRig.anchor takes the excess back out, and this is the check + -- that it did: measured AFTER the anchor, the same way the mode + -- draws it, so what is reported is what a player would actually see. + rig:anchor(TRAVEL) + local cx, cy, cz = bodyCentreOf(rig) + local drift = (((cx - bcx) ^ 2 + (cy - bcy) ^ 2 + (cz - bcz) ^ 2) ^ 0.5) + / rawHeight + -- a species the rig has judged unmeasurable is deliberately NOT + -- anchored (StadiumRig.measureBind), so of course it still travels -- + -- reporting that would be reporting a decision as a defect + if model.anchorOk ~= false and drift > TRAVEL * 1.05 then + report("animation walks the Pokemon off its tile", dex, name, f, + ("body centre %.1f body-heights from where it started") + :format(drift)) + end + + local h, bad = bboxOf(rig) + if bad then + report("posed vertex is NaN or infinite", dex, name, f, "") + elseif h > bind * EXPLODE then + report("pose flies apart", dex, name, f, + ("%.0f units tall against a %.0f-unit bind pose (%.1fx)") + :format(h, bind, h / bind)) + end + -- Every piece of the Pokemon has to have a texture to be drawn with, + -- and one that resolves to nothing is a limb that is simply not + -- there -- EXCEPT where the source says it has none. + -- + -- StadiumPack stores the texture index one-based (`u16 + 1`), so the + -- packer's 0xFFFF "this primitive is untextured" sentinel arrives + -- here as 65536. That is 39 primitives across 37 species, 1.6% of the + -- set's vertices, and every one of them has all-zero UVs -- they are + -- flat-shaded geometry in the original, not a texture that went + -- missing. Reported as a finding they were 104,728 lines of noise + -- (one per prim per sampled frame) burying two real bugs. + -- + -- Counted rather than dropped: "how much of this set is untextured" + -- is worth knowing, and a number that suddenly moves is worth seeing. + for i, part in ipairs(rig.parts) do + if not part.texture then + if part.prim.tex == UNTEXTURED then + untextured[dex] = true + else + report("part has no texture", dex, name, f, + ("prim %d wants texture %s of %d") + :format(i, tostring(part.prim.tex), model.texCount or 0)) + end + end + end + f = f + STEP + end + end + end + + -- ------- and the same species as a BATTLE drives it + -- + -- The sweep above plays the animations; this plays the STATE MACHINE, which + -- is what a fight actually touches: the context slots behind idle, the + -- entrance, the two reactions and the collapse, and then all 165 move slots + -- -- because a move's animation is looked up by move id in a table the + -- species carries, and an index in that table that points nowhere is a + -- crash on the frame that move is used. + local mon = Mon.new("player") + mon.rig, mon.model, mon.species = rig, model, dex + mon.state, mon.anim, mon.time = nil, nil, 0 + + for _, state in ipairs({ "idle", "entrance", "attack", "faint" }) do + local ok, err = pcall(function() + mon:play(state) + -- a whole second of it at 60 Hz, which is what the fight does + for _ = 1, 60 do + mon:update(1 / 60) + mon:build() + end + end) + if not ok then + report("threw while playing", dex, state, nil, tostring(err)) + end + end + + for moveIndex = 1, Pack.N_MOVES do + local slot = model.moveAnim[moveIndex] + if slot and slot ~= Pack.NONE then + if not model.anims[slot + 1] then + report("move points at an animation that is not there", dex, + ("move %d"):format(moveIndex), nil, + ("anim index %d of %d"):format(slot + 1, model.animCount or 0)) + else + local aux = model.moveAux[moveIndex] + if aux and aux >= 0 and not (model.auxAnims and model.auxAnims[aux + 1]) then + report("move points at an aux that is not there", dex, + ("move %d"):format(moveIndex), nil, + ("aux index %d of %d"):format(aux + 1, model.auxCount or 0)) + end + local ok, err = pcall(function() + mon:attack(moveIndex) + for _ = 1, 30 do + mon:update(1 / 60) + mon:build() + end + end) + if not ok then + report("threw while playing", dex, ("move %d"):format(moveIndex), + nil, tostring(err)) + end + end + end + end + + rig:release() + return steps +end + +-- ------- the sweep + +local list = only +if not list then + list = {} + for dex = 1, 151 do list[dex] = dex end +end + +local started = os.clock() +local steps = 0 +local staticPose = {} +for _, dex in ipairs(list) do + local m = Pack.load(dex) + -- SKIPPED, not swept. The packer measures each species' standby loop + -- against its own bind pose and flags the ones whose animation data is + -- corrupt at source, and StadiumMon declines those outright -- they stand + -- as flat battle pics and no frame of them is ever posed in a game. Sweeping + -- them anyway produced 356 of the 362 "pose flies apart" findings, which is + -- a report that is mostly about Pokemon this mode does not draw. + if m and m.staticPose then + staticPose[#staticPose + 1] = dex + else + local ok, err = pcall(function() steps = steps + sweepSpecies(dex) end) + if not ok then + report("the sweep itself threw", dex, nil, nil, tostring(err)) + end + end + if not args.quiet and dex % 10 == 0 then + io.write((" ... %d/%d %.0f MB\n") + :format(dex, #list, collectgarbage("count") / 1024)) + io.flush() + end +end + +-- ------- the LRU, which is the one failure a frame sweep cannot reach +-- +-- A model is evicted by SPECIES COUNT, not by whether anything is still +-- standing on it -- so a battle that has seen five species while two of them +-- are on the field releases the textures of a Pokemon that is still being +-- drawn. That is not something playing one species' animations can produce; +-- it takes a sixth load. Reproduced here directly. +local function sweepEviction() + local held = {} + -- two Pokemon out, as a battle has + for _, dex in ipairs({ 1, 4 }) do + local model = Pack.load(dex) + local rig = model and Rig.new(model) + if rig then held[#held + 1] = { dex = dex, model = model, rig = rig } end + end + if #held < 2 then return end + for _, h in ipairs(held) do + h.rig:pose(1, 0, true) + h.rig:skin(0) + h.rig:textures(nil) + end + -- and then the fight sees more species than the cache keeps + for _, dex in ipairs({ 7, 10, 13, 16, 19, 25 }) do Pack.load(dex) end + for _, h in ipairs(held) do + local ok, err = pcall(function() + h.rig:textures(nil) + h.rig:draw({ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }, 0) + end) + if not ok then + report("threw after the pack cache evicted a model still on the field", + h.dex, "idle", nil, tostring(err)) + end + end + for _, h in ipairs(held) do h.rig:release() end +end +sweepEviction() + +local elapsed = os.clock() - started + +-- ------- what it found + +print("") +print(("stadium animation QA: %d species, %d posed frames, %.1fs") + :format(#list, steps, elapsed)) +print(("packs: %s"):format(packDir)) +local nUntextured = 0 +for _ in pairs(untextured) do nUntextured = nUntextured + 1 end +if nUntextured > 0 then + print(("species with flat-shaded (untextured) primitives: %d -- expected, " + .. "the source has no texture for those"):format(nUntextured)) +end +local anchorList = {} +for dex in pairs(unanchored) do anchorList[#anchorList + 1] = dex end +table.sort(anchorList) +if #anchorList > 0 then + print(("not anchored (standby loop too unsteady to measure, so they travel " + .. "as authored): %s"):format(table.concat(anchorList, " "))) +end +if #staticPose > 0 then + print(("staticPose (declined by the packer, never drawn, not swept): %s") + :format(table.concat(staticPose, " "))) +end +print("") + +local total = 0 +for _, kind in ipairs(kinds) do total = total + #findings[kind] end + +if total == 0 then + print("no findings") + os.exit(0) +end + +for _, kind in ipairs(kinds) do + local list2 = findings[kind] + print(("---- %s (%d)"):format(kind, #list2)) + -- one line per species per kind, with the first example and a count, so a + -- fault that spans every frame of an animation reads as one fault + local seen, order = {}, {} + for _, f in ipairs(list2) do + local key = ("%03d|%s"):format(f.dex or 0, tostring(f.anim)) + if not seen[key] then + seen[key] = { n = 0, f = f } + order[#order + 1] = key + end + seen[key].n = seen[key].n + 1 + end + for _, key in ipairs(order) do + local e = seen[key] + print((" %03d %-14s %s%s%s") + :format(e.f.dex or 0, tostring(e.f.anim), + e.f.frame and ("frame %.1f: "):format(e.f.frame) or "", + e.detail or e.f.detail or "", + e.n > 1 and (" (x%d)"):format(e.n) or "")) + end + print("") +end + +print(("%d findings in %d kinds"):format(total, #kinds)) +os.exit(total > 0 and 1 or 0) diff --git a/tests/stadium_budget_test.lua b/tests/stadium_budget_test.lua new file mode 100644 index 0000000..10c5f90 --- /dev/null +++ b/tests/stadium_budget_test.lua @@ -0,0 +1,149 @@ +-- What the model build COSTS -- the numbers that decide whether it can run +-- on a phone. +-- +-- luajit mods/DramaticShapeVoxelMod/tests/stadium_budget_test.lua [--rom=PATH] +-- +-- Run from the PROJECT ROOT. +-- +-- ------- why this is a test and not a note +-- +-- The extraction is pure Lua on purpose -- no FFI, no native helper, no +-- second process -- specifically so it can run everywhere LOVE does, phones +-- included. That claim is about MEMORY as much as about which functions +-- exist: a desktop will not notice a transient peak that would have a mobile +-- OS kill the process, and the peak is not visible by reading the code. +-- +-- So this measures it, and fails if the two things that must stay bounded +-- stop being bounded: +-- +-- the WORKING SET must not grow with the number of species done, or the +-- build gets heavier the longer it runs and dies somewhere in the 140s +-- +-- the PEAK must stay under a budget a phone can be expected to have +-- spare, on top of a game that is already running +-- +-- The ROM itself is most of the floor and cannot be avoided -- the archive is +-- addressed by absolute offset -- so it is reported separately from the +-- per-species work built on top of it. + +local args = {} +for _, a in ipairs({ ... }) do + local k, v = a:match("^%-%-([%w_]+)=(.*)$") + if k then args[k] = v end +end + +local MOD = "mods/DramaticShapeVoxelMod" +local ROM = args.rom or (MOD .. "/model_extract/baseroms/us/baserom.z64") + +-- How much headroom the whole build may take on top of the ROM, in MB. Sized +-- against the smallest thing this is expected to run on rather than against +-- what is convenient: a mid-range phone from several years ago, with a game +-- already resident. +local PEAK_BUDGET_MB = 220 + +-- How much the working set may drift between the first ten species and the +-- last ten, in MB. Not zero -- species differ in size by a factor of ten, and +-- the collector is not obliged to run on any particular schedule -- but a +-- genuine leak shows up here as tens of megabytes. +local DRIFT_BUDGET_MB = 24 + +local loaded = {} +local V = {} +function V.require(name) + if loaded[name] == nil then + local chunk = assert(loadfile(MOD .. "/lib/" .. name .. ".lua")) + loaded[name] = chunk(V) + end + return loaded[name] +end +V.mod = { log = { warn = function() end, info = function() end } } + +local StadiumRom = V.require("StadiumRom") +local StadiumBuild = V.require("StadiumBuild") + +local function mb() + return collectgarbage("count") / 1024 +end + +-- A single collectgarbage() is not guaranteed to finish a cycle, and a +-- half-finished one reads tens of megabytes high -- which showed up here as +-- "settled" figures BELOW the baseline they were measured against. Run it +-- until the number stops moving. +local function settle() + local last = mb() + for _ = 1, 8 do + collectgarbage("collect") + local now = mb() + if now >= last - 0.05 then return now end + last = now + end + return mb() +end + +-- The baseline is taken BEFORE the ROM is read, or the file is already on the +-- heap when it is measured and the ROM appears to cost nothing. +local base = settle() + +local fp = io.open(ROM, "rb") +if not fp then + io.stderr:write("no ROM at " .. ROM .. "\n") + os.exit(2) +end +local romBytes = fp:read("*a") +fp:close() + +local rom = assert(StadiumRom.open(romBytes)) +local withRom = settle() + +local peak, settled = withRom, {} +local bytes = 0 +local t0 = os.clock() + +for fileno = 0, StadiumRom.N_POKEMON - 1 do + local res = assert(StadiumBuild.species(rom, fileno)) + bytes = bytes + #res.bytes + -- the live peak, before the collector has been asked for anything: this is + -- what the allocator actually had to find + local live = mb() + if live > peak then peak = live end + -- and the settled working set, which is what must not creep + settled[fileno + 1] = settle() +end + +local dt = os.clock() - t0 + +local function avg(from, to) + local sum = 0 + for i = from, to do sum = sum + settled[i] end + return sum / (to - from + 1) +end + +local first = avg(1, 10) +local last = avg(#settled - 9, #settled) +local drift = last - first + +io.write(("ROM %6.1f MB (the file, held for the whole build)\n") + :format(withRom - base)) +io.write(("peak working set %6.1f MB (over the ROM)\n"):format(peak - withRom)) +io.write(("settled, first 10 %6.1f MB\n"):format(first - withRom)) +io.write(("settled, last 10 %6.1f MB\n"):format(last - withRom)) +io.write(("drift %+6.1f MB (budget %.0f)\n") + :format(drift, DRIFT_BUDGET_MB)) +io.write(("total peak %6.1f MB (budget %.0f)\n") + :format(peak, PEAK_BUDGET_MB)) +io.write(("output %6.1f MB in %.1fs (%.0f ms a species)\n") + :format(bytes / 1e6, dt, dt * 1000 / StadiumRom.N_POKEMON)) + +local fail = false +if peak > PEAK_BUDGET_MB then + io.write(("FAIL peak %.1f MB is over the %.0f MB budget\n") + :format(peak, PEAK_BUDGET_MB)) + fail = true +end +if drift > DRIFT_BUDGET_MB then + io.write(("FAIL the working set grew %.1f MB across the run -- something is " + .. "being retained per species\n"):format(drift)) + fail = true +end +if fail then os.exit(1) end +io.write("PASS -- bounded working set, peak within budget\n") diff --git a/tests/stadium_entrance_shots.lua b/tests/stadium_entrance_shots.lua new file mode 100644 index 0000000..b195c6d --- /dev/null +++ b/tests/stadium_entrance_shots.lua @@ -0,0 +1,57 @@ +-- Driver: the send-out ENTRANCE, frame by frame, for the two species the +-- animation QA sweep flags as flying apart in that animation alone. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or ".scratchpad" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local lib = game.mods.exports.DRAMATIC_SHAPE.lib + local Battles = lib.require("OverworldBattle") + local Stadium = lib.require("Stadium") + local Install = lib.require("StadiumInstall") + + game.save.player.name = "RED" + U.teleport(game, "ROUTE_1", 5, 8, "down") + local waited = 0 + while (Install.pending() or Install.status.state == "building") + and waited < 5400 do U.wait(10); waited = waited + 10 end + while game.stack:top() and game.stack:top() ~= game.overworld do U.wait(10) end + + Battles.setting:setValue(os.getenv("DS_RUNG") or "stadiumB", game) + Battles.backSetting:setValue(false, game) + lib.require("DayNight").setting:setValue("day", game) + U.wait(60) + + for _, name in ipairs({ "FARFETCHD", "DEWGONG" }) do + game.save.party = { Pokemon.new(game.data, name, 40) } + local battle = BattleState.newWild(game, "PIKACHU", 20) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + -- ONE loop from the first frame: the send-out needs taps to get past + -- the intro text, and the entrance starts the instant it happens -- so + -- watching and tapping have to be the same loop or the animation is over + -- before the watching begins (which is exactly what a split loop did). + local shot, sawEntrance = 0, false + for f = 1, 900 do + U.wait(1) + local anim = Stadium.animOf("player") + if anim == "entrance" then + sawEntrance = true + if f % 14 == 0 and shot < 8 then + shot = shot + 1 + U.shot(game, ("%s/%s_entrance_%d.png"):format(DIR, name:lower(), shot)) + end + elseif sawEntrance then + break + end + if not sawEntrance and f % 12 == 0 then U.tap(game, "a") end + end + U.log(("%s: took %d entrance stills, anim now %s") + :format(name, shot, tostring(Stadium.animOf("player")))) + while game.stack:top() and game.stack:top() ~= game.overworld do + game.stack:pop() + end + U.wait(10) + end + U.log("done -- " .. DIR) +end diff --git a/tests/stadium_entry_probe.lua b/tests/stadium_entry_probe.lua new file mode 100644 index 0000000..6bb5fea --- /dev/null +++ b/tests/stadium_entry_probe.lua @@ -0,0 +1,84 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local lib = game.mods.exports.DRAMATIC_SHAPE.lib + local Battles = lib.require("OverworldBattle") + local Stadium = lib.require("Stadium") + local Install = lib.require("StadiumInstall") + + U.teleport(game, "ROUTE_1", 5, 8, "down") + local w = 0 + while (Install.pending() or Install.status.state == "building") and w < 5400 do + U.wait(10); w = w + 10 + end + while game.stack:top() and game.stack:top() ~= game.overworld do U.wait(10) end + Battles.setting:setValue("stadium", game) + Battles.backSetting:setValue(false, game) + U.wait(60) + + local marks = {} + local inner = BattleState.startGrowIn + BattleState.startGrowIn = function(self, b) + marks[#marks + 1] = { side = (b == self.player) and "player" or "enemy" } + return inner(self, b) + end + + game.save.party = { Pokemon.new(game.data, "PIDGEY", 30) } + local battle = BattleState.newWild(game, "PIKACHU", 5) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + + local seen, lastState, lastGrow, nMark = {}, "?", false, 0 + local shotAt = {} + for f = 1, 500 do + U.wait(1) + if f % 10 == 0 and battle.phase ~= "menu" then U.tap(game, "a") end + local st = Stadium.animOf("player") or "-" + local grow = battle.growIn ~= nil + if #marks > nMark then + nMark = #marks + U.log(("f=%3d >> startGrowIn(%s) player model present: %s, state %s") + :format(f, marks[nMark].side, tostring(Stadium.showing("player")), + st)) + end + local poof = (battle.animPlaying and battle.animName == "POOF_ANIM") + and true or false + local key = ("%s|%s|%s|%s|%s|%s|%s|%s|%s"):format(st, + tostring(math.floor((Stadium.scaleOf("player") or 1) * 20)), + tostring(poof), tostring(battle.sendingOut), + tostring(battle.phase), tostring(Stadium.showing("player")), + tostring(battle.showPlayerBack), tostring(battle.playerBackPic ~= nil), + tostring((battle.introSlide or 0) > 0)) + if key ~= lastState then + U.log(("f=%3d state=%-9s sendingOut=%-5s poof=%-5s growIn=%-5s " + .. "growScale=%-6s model=%s") + :format(f, st, tostring(battle.sendingOut), tostring(poof), + tostring(grow), tostring(Stadium.scaleOf("player")), + tostring(Stadium.showing("player")))) + lastState = key + end + -- stills across the grow, so the ramp can be looked at and not just read + local sc = Stadium.scaleOf("player") + if Stadium.showing("player") and sc then + for _, want in ipairs({ 0.15, 0.45, 0.75, 1.0 }) do + if not shotAt[want] and sc >= want then + shotAt[want] = true + U.shot(game, ("%s/grow_%02d.png") + :format(os.getenv("SHOT_DIR") or ".", want * 100)) + end + end + end + seen[st] = true + end + for i = 1, 4 do + U.shot(game, (os.getenv("SHOT_DIR") or ".") .. ("/pidgey_%d.png"):format(i)) + U.wait(2) + end + local list = {} + for k in pairs(seen) do list[#list + 1] = k end + table.sort(list) + U.log("states the player's side passed through: " .. table.concat(list, ", ")) + U.log("startGrowIn calls: " .. #marks) + U.log("done") +end diff --git a/tests/stadium_extract_test.lua b/tests/stadium_extract_test.lua new file mode 100644 index 0000000..626d517 --- /dev/null +++ b/tests/stadium_extract_test.lua @@ -0,0 +1,128 @@ +-- The Lua ROM extractor, against the Python packer that is its oracle. +-- +-- luajit mods/DramaticShapeVoxelMod/tests/stadium_extract_test.lua \ +-- [--rom=PATH] [--oracle=DIR] [--only=25,6] [--out=DIR] +-- +-- Run from the PROJECT ROOT. Defaults: the ROM under +-- model_extract/baseroms/, the oracle in assets/stadium (whatever +-- tools/stadium_pack.py last wrote there). +-- +-- ------- what this is for +-- +-- lib/StadiumRom, StadiumFragment, StadiumFx and StadiumBuild are a port of +-- roughly fifteen hundred lines of dense numeric Python -- an F3DEX2 +-- interpreter, six texture codecs, a bit-packed animation sampler, a noise +-- generator and a binary writer. Reading a port of that twice does not +-- establish that it is right. Producing the same thirty-four megabytes, +-- byte for byte, does. +-- +-- It is also the guard on the two of them DRIFTING. The packer and the +-- extractor have to keep agreeing about the format forever, and a change to +-- one that is not made to the other shows up here as a differing offset +-- rather than as a Pokemon that renders as noise three months later. +-- +-- Headless: no LOVE, no graphics. Everything these four modules do is +-- arithmetic on strings. + +local args = {} +for _, a in ipairs({ ... }) do + local k, v = a:match("^%-%-([%w_]+)=(.*)$") + if k then args[k] = v else args[a:gsub("^%-%-", "")] = true end +end + +local MOD = "mods/DramaticShapeVoxelMod" +local ROM = args.rom or (MOD .. "/model_extract/baseroms/us/baserom.z64") +local ORACLE = args.oracle or (MOD .. "/assets/stadium") + +-- ------- the mod namespace, enough of it to load four modules + +local loaded = {} +local V = {} +function V.require(name) + if loaded[name] == nil then + local chunk = assert(loadfile(MOD .. "/lib/" .. name .. ".lua")) + loaded[name] = chunk(V) + end + return loaded[name] +end +V.mod = { log = { warn = function() end, info = function() end } } + +local StadiumRom = V.require("StadiumRom") +local StadiumBuild = V.require("StadiumBuild") + +-- ------- run + +local function readFile(path) + local fp = io.open(path, "rb") + if not fp then return nil end + local data = fp:read("*a") + fp:close() + return data +end + +local romBytes = readFile(ROM) +if not romBytes then + io.stderr:write("no ROM at " .. ROM .. "\n") + os.exit(2) +end + +local rom, err = StadiumRom.open(romBytes) +if not rom then + io.stderr:write("could not open ROM: " .. tostring(err) .. "\n") + os.exit(2) +end + +local only = nil +if args.only then + only = {} + for n in args.only:gmatch("%d+") do only[tonumber(n)] = true end +end + +local checked, matched, missing, failed = 0, 0, 0, 0 +local firstBad = nil +local t0 = os.clock() + +for fileno = 0, StadiumRom.N_POKEMON - 1 do + local ok, res = pcall(StadiumBuild.species, rom, fileno) + if not ok or not res then + -- res carries the message on the error path + io.write(("file %d: EXTRACT FAILED: %s\n"):format(fileno, tostring(res))) + failed = failed + 1 + elseif not only or only[res.species] then + checked = checked + 1 + if args.out then + local fp = io.open(("%s/%03d.dsm"):format(args.out, res.species), "wb") + if fp then fp:write(res.bytes) fp:close() end + end + local want = readFile(("%s/%03d.dsm"):format(ORACLE, res.species)) + if not want then + missing = missing + 1 + elseif want == res.bytes then + matched = matched + 1 + else + -- where, exactly: the first differing byte localises a format mistake + -- far faster than "the file is wrong" does + local at = nil + local n = math.min(#want, #res.bytes) + for i = 1, n do + if want:sub(i, i) ~= res.bytes:sub(i, i) then at = i break end + end + io.write(("%03d.dsm DIFFERS: %d bytes vs %d, first at %s\n") + :format(res.species, #res.bytes, #want, + at and ("offset " .. at) or "(one is a prefix)")) + if not firstBad then firstBad = res.species end + end + end +end + +io.write(("\n%d checked, %d identical, %d differ, %d oracle files missing, " + .. "%d extractions failed (%.1fs)\n") + :format(checked, matched, checked - matched - missing, missing, + failed, os.clock() - t0)) + +if matched == checked and failed == 0 and missing == 0 then + io.write("PASS -- the Lua extractor reproduces the packer exactly\n") + os.exit(0) +end +io.write("FAIL\n") +os.exit(1) diff --git a/tests/stadium_import.lua b/tests/stadium_import.lua new file mode 100644 index 0000000..eed2f11 --- /dev/null +++ b/tests/stadium_import.lua @@ -0,0 +1,131 @@ +-- Driver: import a Stadium ROM through the OPTIONS row, with the modal file +-- dialog stubbed out. +-- +-- Everything except the dialog itself is the real path: the row is built the +-- way the OPTIONS menu builds it, its step() is pressed, StadiumRomPick reads +-- the file with io.open (love.filesystem cannot see an absolute path), +-- StadiumInstall builds from those bytes, and the loading screen goes up over +-- whatever asked. A modal dialog cannot be driven, so choose() is replaced +-- with the answer a player would have given it. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or ".scratchpad" + local lib = game.mods.exports.DRAMATIC_SHAPE.lib + local Pick = lib.require("StadiumRomPick") + local Install = lib.require("StadiumInstall") + local Pack = lib.require("StadiumPack") + local Battles = lib.require("OverworldBattle") + + U.teleport(game, "ROUTE_1", 5, 8, "down") + U.wait(30) + + -- FIRST let the automatic first-run build finish. A ROM is sitting in + -- baseroms/ in this checkout, so StadiumScreen.maybePush has already pushed + -- a loading screen and started one -- and import() refuses to start a + -- second while that runs, so every step below would silently no-op against + -- it. maybePush asks ONCE per boot, so once this is drained it stays + -- drained and the purge below cannot be undone behind our backs. + local drain = 0 + while (Install.pending() or Install.status.state == "building") + and drain < 5400 do + U.wait(10); drain = drain + 10 + end + while game.stack:top() and game.stack:top() ~= game.overworld do + U.wait(10) + end + U.log(("drained the automatic first-run build in %d frames"):format(drain)) + + -- back to a machine that has never built them, whatever earlier runs left + for _, name in ipairs(love.filesystem.getDirectoryItems(Pack.CACHE_DIR)) do + love.filesystem.remove(Pack.CACHE_DIR .. "/" .. name) + end + Install.forget() + Pack.forget() + + local function settle(what) + local waited = 0 + while Install.status.state == "building" and waited < 5400 do + U.wait(10) + waited = waited + 10 + end + U.log(("%s: %d frames (%.1fs) -- state=%s error=%s") + :format(what, waited, waited / 60, tostring(Install.status.state), + tostring(Install.status.error))) + return waited + end + + U.log(("picker available on this platform: %s"):format( + tostring(Pick.available()))) + U.log(("BEFORE: models available = %s, 3D-BTL offers %d rungs") + :format(tostring(Install.available()), Battles.setting:rungs())) + + local row = Pick.row() + if not row then U.log("no import row on this platform") return end + U.log(("row reads: %s = %s"):format(row.label, row.value())) + + -- ------- the wrong file first, because a refusal must not leave a mess + U.log("-- picking a file that is not a Stadium ROM --") + Pick.choose = function() return "/not/a/real/path.z64" end + local realRead = Pick.read + Pick.read = function() return ("\x80\x37\x12\x40"):rep(4096) end + row.step(game) + settle("wrong file") + U.shot(game, ("%s/import_1_refused.png"):format(DIR)) + U.log((" still not installed: %s, row still reads %s") + :format(tostring(not Install.available()), row.value())) + U.wait(300) -- let the failure notice time out and pop itself + + -- ------- and now the real one + U.log("-- picking the real ROM --") + Pick.read = realRead + local abs = love.filesystem.getWorkingDirectory() .. "/baseroms/baserom.z64" + local picked = false + Pick.choose = function() picked = true return abs end + Install.status.state = "idle" + row.step(game) + U.log((" choose() called: %s, state = %s, loading screen on top: %s") + :format(tostring(picked), tostring(Install.status.state), + tostring(game.stack:top() ~= game.overworld))) + U.wait(30) + U.shot(game, ("%s/import_2_building.png"):format(DIR)) + settle("real ROM") + U.wait(120) + + U.log(("AFTER: models available = %s, 3D-BTL offers %d rungs, row reads %s") + :format(tostring(Install.available()), Battles.setting:rungs(), + row.value())) + Battles.setting:setValue("stadium", game) + U.log((" and 3D-BTL can now be set to STADIUM A: %s") + :format(tostring(Battles.setting:get() == "stadium"))) + -- and the ROM was NOT copied anywhere: the only baserom physfs can see is + -- the one in the game folder this checkout already had, never a copy in + -- the save directory (which is where a mod's writes land) + local where = love.filesystem.getRealDirectory("baseroms/baserom.z64") + U.log((" the only ROM on the read path is the game folder's own: %s (%s)") + :format(tostring(where == love.filesystem.getWorkingDirectory()), + tostring(where))) + U.log((" the save directory has no baseroms folder: %s") + :format(tostring(love.filesystem.getInfo( + love.filesystem.getSaveDirectory() .. "/baseroms") == nil))) + + -- ------- and the wrong file, now that a GOOD set is installed + -- + -- The serious case. The marker is the only thing that makes 151 files on + -- disk count as installed, so a refusal that writes one anyway would + -- UNINSTALL a working set -- and the player would have pressed a row called + -- STADIUM ROM, picked the wrong file, and lost the models they already had. + U.log("-- picking the wrong file again, with models already installed --") + Pick.read = function() return ("€7@"):rep(4096) end + Install.status.state = "idle" + row.step(game) + settle("wrong file over a good install") + U.wait(300) + Install.forget() + Pack.forget() + U.log((" the good install SURVIVED: %s, row reads %s, 3D-BTL offers %d rungs") + :format(tostring(Install.available()), row.value(), + Battles.setting:rungs())) + U.log((" and a real model still loads: %s") + :format(tostring(Pack.load(25) ~= nil))) + U.log("done -- " .. DIR) +end diff --git a/tests/stadium_pack_probe.lua b/tests/stadium_pack_probe.lua new file mode 100644 index 0000000..fb1d273 --- /dev/null +++ b/tests/stadium_pack_probe.lua @@ -0,0 +1,115 @@ +-- Probe: read the .dsm packs back with the mod's OWN reader and print what +-- it made of them, so the Lua side can be diffed against the packer's +-- --report output. Headless -- no love.graphics, no meshes. +-- +-- luajit mods/DramaticShapeVoxelMod/tests/stadium_pack_probe.lua [dex ...] +-- +-- Run from the repository root. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local ROOT = "mods/DramaticShapeVoxelMod" + +-- the mod namespace lib/ modules expect (see main.lua), with just enough of +-- it for the two files under test +local V = {} +local modules = {} +V.mod = { + log = { + warn = function(_, fmt, ...) print("[warn] " .. fmt:format(...)) end, + info = function(_, fmt, ...) print("[info] " .. fmt:format(...)) end, + }, + read = function(_, rel) + local fp = io.open(ROOT .. "/" .. rel, "rb") + if not fp then return nil end + local bytes = fp:read("*a") + fp:close() + return bytes + end, +} +function V.require(name) + if modules[name] then return modules[name] end + local chunk = assert(loadfile(ROOT .. "/lib/" .. name .. ".lua")) + modules[name] = chunk(V) + return modules[name] +end + +-- StadiumRig pulls in Voxel3D, which needs love; the pose walk itself does +-- not, so the require is stubbed out to the one field it reads. +modules.Voxel3D = { FORMAT = {} } + +local Pack = V.require("StadiumPack") +local Rig = V.require("StadiumRig") + +local want = {} +for i = 1, #arg do want[#want + 1] = tonumber(arg[i]) end +if #want == 0 then want = { 25, 6, 130, 95, 50, 10, 143, 92, 41, 73 } end + +print(("%-4s %-6s %6s %6s %6s %8s %8s %8s %8s") + :format("dex", "bones", "prims", "anims", "tex", "root", "height", + "floor", "radius")) +for _, dex in ipairs(want) do + local m = Pack.load(dex) + if not m then + print(("%-4d -- did not load"):format(dex)) + else + print(("%-4d %-6d %6d %6d %6d %8.4f %8.2f %8.2f %8.2f") + :format(dex, m.boneCount, m.primCount, m.animCount, m.texCount, + m.rootScale, m.height, m.floor, m.radius)) + + -- the bind pose, walked by the REAL rig code on a bare instance (no + -- meshes, so no graphics context needed), measured the same way + -- tools/stadium_pack.py measures it. Agreement here means the byte + -- format, the bone tree, the rotation basis and the two-chain scale + -- split all survived the trip into Lua. + local rig = setmetatable({ + model = m, pivotM = {}, drawM = {}, accX = {}, accY = {}, accZ = {}, + parts = {}, + }, Rig) + local function boxAt(anim, frame, wrap) + rig:pose(anim, frame, wrap) + local drw = rig.drawM + local lo, hi = math.huge, -math.huge + for _, prim in ipairs(m.prims) do + for k = 1, prim.vertCount do + local o = (prim.bone[k] - 1) * 12 + local x, y, z = prim.px[k], prim.py[k], prim.pz[k] + local wy = drw[o + 5] * x + drw[o + 6] * y + drw[o + 7] * z + + drw[o + 8] + wy = wy * m.rootScale + if wy < lo then lo = wy end + if wy > hi then hi = wy end + end + end + return hi - lo, lo + end + + local h, f = boxAt(nil, 0, false) + print((" bind pose walked in Lua: height %8.2f floor %8.2f " + .. "(header says %8.2f / %8.2f)%s") + :format(h, f, m.height, m.floor, + (math.abs(h - m.height) < 0.5 + and math.abs(f - m.floor) < 0.5) and " OK" or " MISMATCH")) + + -- the animation tables, decoded lazily like the game does it + local idle = m.ctx[Pack.SLOT.idle] + local names = {} + for i, a in ipairs(m.anims) do names[i] = a.name end + print((" anims: %s"):format(table.concat(names, " "))) + print((" idle slot -> %s, entrance -> %s, faint -> %s") + :format(tostring(idle), tostring(m.ctx[Pack.SLOT.entrance]), + tostring(m.ctx[Pack.SLOT.faint]))) + local tracks = Pack.tracks(m, (idle or 0) + 1) + local animated = 0 + for b = 1, m.boneCount do if tracks and tracks[b] then animated = animated + 1 end end + print((" idle: %d frames, %d/%d bones animated") + :format(m.anims[(idle or 0) + 1].frames, animated, m.boneCount)) + -- the POSED extent, which is what actually gets drawn. Compare against + -- tools/stadium_pack.py's anim_sample, which was checked frame for + -- frame against the reference glTF export. + for _, fr in ipairs({ 0, 1, 5 }) do + local ah, af = boxAt((idle or 0) + 1, fr, true) + print((" idle frame %d: y %8.2f .. %8.2f"):format(fr, af, af + ah)) + end + end +end diff --git a/tests/stadium_shots.lua b/tests/stadium_shots.lua new file mode 100644 index 0000000..203c026 --- /dev/null +++ b/tests/stadium_shots.lua @@ -0,0 +1,379 @@ +-- Driver: screenshot a STADIUM battle -- the 3D-BTL row's third rung, where +-- the two Pokemon are the Pokemon Stadium battle models rather than the +-- game's own pics stood up on their tiles. +-- +-- What has to be looked at is a SPREAD OF SPECIES, because almost everything +-- that can go wrong here is per-model: how big it comes out (the size ladder +-- compresses a sixteenfold spread of authored heights -- see StadiumMon), +-- whether its feet are on the ground (the floor rule has to tell a hovering +-- Zubat from a Tentacruel centred on its own origin), whether the eyes +-- animate, and whether the species carries generated flame prims. So the +-- list below is deliberately awkward: the smallest and the largest in the +-- set, a hoverer, a floater, and the two species whose idle animation is +-- known to be wrong in the source data. +-- +-- SHOT_DIR=.scratchpad DS_STADIUM_DEBUG=1 \ +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/stadium_shots.lua love . +-- +-- SHOT_DIR must already exist: the capture writes with io.open, which does +-- not create directories. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or ".scratchpad" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + local exports = game.mods and game.mods.exports + local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib + if not lib then + U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again") + return + end + local Battles = lib.require("OverworldBattle") + local Stadium = lib.require("Stadium") + local Pack = lib.require("StadiumPack") + + local Install = lib.require("StadiumInstall") + + game.save.player.name = "RED" + + -- foe, and the player's own, chosen to put two different problems in one + -- frame wherever possible + local CASES = { + { "PIKACHU", "CHARIZARD" }, -- the reference pair; Charizard has flame + { "ONIX", "DIGLETT" }, -- the tallest against nearly the shortest + { "ZUBAT", "TENTACRUEL" }, -- hovers above its origin / hangs below it + { "GASTLY", "CATERPIE" }, -- a floater and the smallest in the set + -- the two species whose animation data is corrupt at source: BOTH of + -- these must come out as flat battle pics standing on their tiles, not + -- as models (see StadiumMon.setSpecies). Tangela is the third. + { "EXEGGUTOR", "MAGMAR" }, + { "GYARADOS", "SNORLAX" }, -- the two biggest bodies in the set + } + + -- ONTO THE MAP FIRST, before anything below waits on anything. + -- + -- The models are built out of the player's own ROM on first run, on a + -- loading screen, and that screen is pushed from the mod's update hook on + -- the first frame THE OVERWORLD IS THE TOP STATE (StadiumScreen.maybePush). + -- A driver that waits for the build before it has got the player onto a map + -- waits forever: the condition that starts the build is the very thing the + -- waiting is postponing. + -- DS_MAP puts the fight somewhere else. It exists for STADIUM B, whose + -- whole claim is that it carries its stage: the shot has to be checked in a + -- CAVE and in a SHOP as well as on a route, both because those are the + -- places the map-staged rungs decline, and because the rung is supposed to + -- take that place's own sky and light with it. + local MAP = os.getenv("DS_MAP") or "ROUTE_1" + local MX = tonumber(os.getenv("DS_X") or "") or 5 + local MY = tonumber(os.getenv("DS_Y") or "") or 8 + U.teleport(game, MAP, MX, MY, "down") + + -- Now wait it out, and say how long it took -- that number is what decides + -- whether the loading screen is acceptable rather than merely correct. + local waited, shotLoad = 0, false + while (Install.pending() or Install.status.state == "building") + and waited < 5400 do + if not shotLoad and Install.status.state == "building" + and (Install.status.done or 0) > 20 then + U.shot(game, ("%s/00_loading.png"):format(DIR)) + shotLoad = true + end + U.wait(10) + waited = waited + 10 + end + if waited > 0 then + U.log(("waited %d frames (%.1fs) for the model build -- %s") + :format(waited, waited / 60, tostring(Install.status.state))) + if Install.status.error then + U.log("build error: " .. tostring(Install.status.error)) + end + end + U.log(("rom present: %s, packs ready: %s, stadium available: %s") + :format(tostring(Install.romPresent()), tostring(Install.ready()), + tostring(Install.available()))) + + -- the rung under test, and the back pic OFF: BACK SPRITES would keep the + -- player's own side on the menu, which is exactly the half of the shot + -- this driver exists to look at + local RUNGS = { cards = true, cardsb = "flatB", a = "stadium", + b = "stadiumB" } + local rung = RUNGS[os.getenv("DS_RUNG") or "a"] or "stadium" + Battles.setting:setValue(rung, game) + Battles.backSetting:setValue(false, game) + -- and PIN THE CLOCK. The hour multiplies everything in the shot -- the sky, + -- the tint, the weight of the shadows -- so a driver left on CYCLE + -- photographs a different scene every run, and two runs of it cannot be + -- compared. DS_TOD overrides for a deliberate look at dusk or night. + lib.require("DayNight").setting:setValue(os.getenv("DS_TOD") or "day", game) + U.log(("3D-BTL = %s, stadium enabled = %s, discs = %s") + :format(tostring(Battles.setting:get()), tostring(Stadium.enabled()), + tostring(Stadium.discs()))) + + -- did the models actually get built? one line rather than 151 silent + -- declines + local have, missing = 0, {} + for dex = 1, 151 do + if Pack.available(dex) then have = have + 1 + elseif #missing < 6 then missing[#missing + 1] = dex end + end + U.log(("stadium packs present: %d/151%s"):format( + have, #missing > 0 and (" (missing " .. table.concat(missing, ",") + .. "...)") or "")) + + -- let the neighbourhood's meshes land before the first fight, so the + -- opening frame is not the flat fallback + U.wait(90) + + -- ------- DS_BLINK: how often does the eye actually change? + -- + -- The texture animation rides the SKELETAL animation's frame and clamps + -- past the end of its own stream (StadiumRig.textures). Get either wrong + -- and a blink becomes a twitch: Rattata's standby loop is forty frames and + -- its blink is five, so wrapping on the blink's own length plays it six + -- times a second. + -- + -- Counted rather than looked at, because "too fast" is a RATE and a + -- screenshot has no rate in it. This walks the idle loop frame by frame and + -- reports how many times the eye texture changes per second. + if os.getenv("DS_BLINK") then + local Pack = lib.require("StadiumPack") + local Rig = lib.require("StadiumRig") + for _, dex in ipairs({ 19, 4, 1, 25 }) do + local model = Pack.load(dex) + local rig = model and Rig.new(model) + if rig then + local idle = model.ctx[Pack.SLOT.idle] + local anim = (idle ~= Pack.NONE) and (idle + 1) or nil + local rec = anim and model.anims[anim] + -- which prim carries the eye, and what it shows each frame + local seen, changes, last = {}, 0, nil + local SECONDS = 4 + for f = 0, SECONDS * Pack.FPS - 1 do + rig:pose(anim, f, true) + rig:textures(rec and rec.aux or nil) + local key = {} + for i, part in ipairs(rig.parts) do + if part.prim.texAnim and part.prim.texAnim >= 0 then + key[#key + 1] = i .. ":" .. tostring(part.texture) + end + end + key = table.concat(key, ",") + if last and key ~= last then changes = changes + 1 end + last = key + seen[key] = true + end + local looks = 0 + for _ in pairs(seen) do looks = looks + 1 end + local period = rec and (rec.frames / Pack.FPS) or 0 + U.log(("BLINK dex %3d: idle %s frames (%.2fs), aux %s frames -- " + .. "%d texture changes in %ds, %d distinct looks; the idle " + .. "loop comes round every %.2fs, so that is %.1f changes a " + .. "cycle") + :format(dex, tostring(rec and rec.frames), period, + tostring(rec and rec.aux + and model.auxAnims[rec.aux].frames), + changes, SECONDS, looks, period, + period > 0 and changes / (SECONDS / period) or 0)) + rig:release() + end + end + U.log("done -- " .. DIR) + return + end + + -- ------- DS_FLY: does the Pokemon actually leave? + -- + -- FLY and DIG take the user off the field for a turn, and the engine says + -- so through `picFx[battler].hidden` rather than through fxHidden (which is + -- the damage blink alone). A model that ignores that stands on its tile + -- while every attack aimed at it misses into empty air. + -- + -- A TIMELINE again, not a still: what is being checked is that the model + -- goes when the pic goes and comes back when it comes back, which is three + -- moments and a screenshot has one. + if os.getenv("DS_FLY") then + local Stadium2 = lib.require("Stadium") + local move = os.getenv("DS_MOVE") or "FLY" + local mine = Pokemon.new(game.data, "CHARIZARD", 60) + -- give it the move outright: what is under test is the vanish, not + -- whether this species learns it by level 60 + mine.moves = { { id = move, pp = 15 } } + game.save.party = { mine } + local battle = BattleState.newWild(game, "PIKACHU", 5) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + U.wait(70) + for _ = 1, 40 do + if battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(10) + end + U.tap(game, "a") -- FIGHT + U.wait(12) + U.tap(game, "a") -- the first (only) move + + local goneAt, backAt, wasGone = nil, nil, false + for f = 1, 400 do + if f % 20 == 0 then U.tap(game, "a") end + U.wait(1) + local me = battle.player + local pf = battle.picFx and battle.picFx[me] + local picHidden = (pf and pf.hidden) and true or false + local showing = Stadium2.showing("player") + if f % 25 == 0 or (picHidden ~= wasGone) then + U.log((" f=%3d picHidden=%-5s model=%-5s anim=%s") + :format(f, tostring(picHidden), tostring(showing), + tostring(Stadium2.animOf("player")))) + end + if picHidden and not goneAt then goneAt = f end + if picHidden and not showing and not wasGone then + wasGone = true + U.shot(game, ("%s/fly_1_gone.png"):format(DIR)) + end + if wasGone and not picHidden and not backAt then + backAt = f + U.shot(game, ("%s/fly_2_back.png"):format(DIR)) + break + end + end + U.log(("%s: the pic hid at frame %s, the model was gone with it (%s), " + .. "and both came back at %s") + :format(move, tostring(goneAt), tostring(wasGone), tostring(backAt))) + U.log(wasGone and " OK -- the model leaves the field with the pic" + or " WRONG -- the model stood there while the game said it was gone") + while game.stack:top() and game.stack:top() ~= game.overworld do + game.stack:pop() + end + U.wait(10) + U.log("done -- " .. DIR) + return + end + + -- ------- DS_FAINT: does the collapse wait for the bar? + -- + -- The faint animation must not start until the foe's HP bar has finished + -- emptying: `onFaint` fires the instant HP reaches zero, but the engine + -- queues the visible collapse behind the move animation and the drain, and + -- a Pokemon that lies down while its own health is still draining above it + -- reads as a bug (see Stadium's faintReady). + -- + -- A TIMELINE rather than a screenshot, because what is being checked is an + -- ORDER: the frame the bar reaches zero against the frame the animation + -- changes. One printed line per frame settles that; two stills cannot. + if os.getenv("DS_FAINT") then + local Stadium2 = lib.require("Stadium") + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 60) } + local battle = BattleState.newWild(game, "PIKACHU", 5) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + U.wait(70) + for _ = 1, 40 do + if battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(10) + end + U.tap(game, "a") + U.wait(12) + U.tap(game, "a") + + local barZeroAt, animAt, shot, stood = nil, nil, false, 0 + for f = 1, 700 do + -- keep advancing the text, as a player would. Without this the queue + -- blocks on the damage message and the HP bar never drains at all -- + -- which is a perfectly good demonstration that the collapse now waits, + -- and no demonstration whatever that it eventually arrives. + if f % 20 == 0 then U.tap(game, "a") end + U.wait(1) + local foe = battle.enemy + local shown = foe and foe.shownHP + local anim = Stadium2.animOf and Stadium2.animOf("enemy") or nil + if f % 25 == 0 or (shown and shown <= 0 and not barZeroAt) then + U.log((" f=%3d phase=%s shownHP=%s hp=%s queued=%s anim=%s") + :format(f, tostring(battle.phase), tostring(shown), + tostring(foe and foe.mon and foe.mon.hp), + tostring(foe and foe.faintQueued), tostring(anim))) + end + if shown and shown <= 0 and not barZeroAt then barZeroAt = f end + if anim == "faint" and not animAt then animAt = f end + -- a still from the middle of the drain: the foe must still be standing + if shown and shown > 0 and not shot and barZeroAt == nil + and foe and foe.faintQueued then + U.shot(game, ("%s/faint_1_draining.png"):format(DIR)) + shot = true + end + -- and how long the model then STAYS. The engine takes the flat pic off + -- after a fourteen-frame slide; the animation runs for one to eight + -- seconds, and being cut off mid-fall is worse than not falling at all. + if animAt and Stadium2.showing("enemy") then stood = f - animAt end + if animAt and f == animAt + 20 then + U.shot(game, ("%s/faint_2_collapsing.png"):format(DIR)) + end + if animAt and f == animAt + 90 then + U.shot(game, ("%s/faint_3_still_falling.png"):format(DIR)) + end + if animAt and f > animAt + 20 and not Stadium2.showing("enemy") then + U.shot(game, ("%s/faint_4_gone.png"):format(DIR)) + break + end + end + U.log(("FAINT: bar reached zero at frame %s, faint animation began at %s") + :format(tostring(barZeroAt), tostring(animAt))) + if barZeroAt and animAt then + U.log((animAt >= barZeroAt) + and " OK -- the collapse waits for the bar" + or " WRONG -- the collapse ran while the bar was still draining") + end + U.log((" the model stayed on the field for %d frames (%.2fs) after the " + .. "collapse began; the engine's own pic slide is 14 (%.2fs)") + :format(stood, stood / 60, 14 / 60)) + while game.stack:top() and game.stack:top() ~= game.overworld do + game.stack:pop() + end + U.wait(10) + U.log("done -- " .. DIR) + return + end + + for i, case in ipairs(CASES) do + local foe, mine = case[1], case[2] + local tag = ("%02d_%s_vs_%s"):format(i, mine:lower(), foe:lower()) + game.save.party = { Pokemon.new(game.data, mine, 40) } + + local battle = BattleState.newWild(game, foe, 30) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + + -- The wipe, then the intro chatter, and -- the part that matters -- + -- far enough past it that the player's own Pokemon has been SENT OUT. + -- Before that the player's side is legitimately the trainer's back + -- sprite, with no Pokemon on the field to model at all; a shot taken + -- there looks exactly like a bug and is not one. + U.wait(70) + -- tap until the FIGHT menu is up, which is the first moment BOTH + -- Pokemon are on the field in their standby loop + for _ = 1, 40 do + if battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(10) + end + U.wait(45) + U.shot(game, ("%s/%s_1_idle.png"):format(DIR, tag)) + + -- FIGHT -> the first move, which is where the attack animation is + U.tap(game, "a") + U.wait(12) + U.tap(game, "a") + U.wait(22) + U.shot(game, ("%s/%s_2_attack.png"):format(DIR, tag)) + U.wait(45) + U.shot(game, ("%s/%s_3_hit.png"):format(DIR, tag)) + + while game.stack:top() and game.stack:top() ~= game.overworld do + game.stack:pop() + end + U.wait(10) + end + + U.log("done -- " .. DIR) +end diff --git a/tools/stadium_pack.py b/tools/stadium_pack.py new file mode 100644 index 0000000..9bd2e85 --- /dev/null +++ b/tools/stadium_pack.py @@ -0,0 +1,572 @@ +#!/usr/bin/env python3 +"""Pack the Pokemon Stadium battle models into the mod's own .dsm format. + + tools/stadium_pack.py [--rom=PATH] [--out=assets/stadium] + [--only=25,6] [--report] + +Reads the ROM directly, through model_extract/pipeline -- the same modules, +in the same order, that lib/StadiumRom.lua and lib/StadiumFragment.lua port to +Lua so the mod can do this itself at runtime with no Python and no checked-in +extract. That parallel is the point of this tool now: it is the ORACLE the +Lua extractor is verified against (tests/stadium_extract_test.lua diffs all +151 files byte for byte), so anything that changes the bytes has to change on +both sides or the suite says so. + +The output is one file per species, `NNN.dsm`, holding geometry, the bone +tree, every animation, the textures and the battle system's own slot tables +(which animation each move plays, and which one each battle context asks +for). Nothing here is decoded at runtime beyond byte order: lib/ +StadiumPack.lua walks these files straight into arrays. + +Textures are stored as RAW RGBA8 rather than PNG, which is why the magic is +DSM3. Two reasons, and the second is the one that decided it. A PNG costs a +decode on the frame a battle starts, where an ImageData over the bytes costs +nothing. And PNG means zlib: Python's deflate and LOVE's are both valid and +need not agree byte for byte, so a packed PNG would make this file impossible +to check the Lua extractor against. Uncompressed pixels are the same pixels +whoever wrote them. + +Everything is little-endian, so the reader's byte pairs go (low, high). + + header + "DSM3" magic + u16 species, bones, prims, textures, anims, auxAnims + f32 rootScale the model_root's own scale + u8 staticPose 1 = never animate + f32 height, floor, radius the bind pose's extent, + in GAME units after + rootScale -- what the mod + sizes a mon on its tile by + u16 moveAnim[165] move id -> animation + i16 moveAux[165] move id -> texture anim + u16 ctxAnim[20] slot 165..184 -> animation + bones[] + i16 parent (-1 root) + i16 t[3] rest translation + i16 r[3] rest rotation, binary + angles (32768 = pi) + i32 s[3] rest scale, 16.16 fixed + prims[] + u16 texture + u8 cull 1 = cull back faces + u8 blend 1 = additive (fx flipbook) + i16 texAnimChannel (-1 none) + u8 texMapN, then texMapN * (u8 key, u16 texture) + u16 fxN, then fxN * u16 flipbook texture frames + u16 verts, indices + verts * (i16 x, y, z ; i16 u, v at 1/512 ; i8 nx, ny, nz ; u8 bone) + indices * u16 + textures[] + u16 w, h ; u32 length ; length RGBA8 bytes (= w * h * 4) + anims[] + u8 name length, then the name + u16 frames, loopStart ; i16 aux (-1 none) + bones * track: + u8 1 if this bone is animated at all, else 0 + when animated, nine components in order tx ty tz rx ry rz sx sy sz, + each u8 kind (0 constant, 1 one value a frame) + then 1 or `frames` values -- i16 for t and r, i32 16.16 for s + auxAnims[] + u16 frames, loopStart, channels + channels * (u16 length, then that many u16 texture-table indices) + +Stdlib only, like the extraction pipeline it drives. +""" + +import math +import os +import struct +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +MOD = os.path.dirname(HERE) +PIPELINE = os.path.join(MOD, 'model_extract', 'pipeline') +sys.path.insert(0, PIPELINE) + +import battle # noqa: E402 (needs PIPELINE on the path first) +import build # noqa: E402 +import fragment # noqa: E402 +import rom as rom_mod # noqa: E402 + +# The battle system's fixed context slots, in slot order from 165. Names are +# manifest.json's own (animationSlots); the mod indexes this list by position, +# so the ORDER is the contract and must match lib/StadiumPack.lua's CONTEXT. +CONTEXTS = [ + 'idle', 'attack_default', 'faint', 'entrance', 'reaction_169', 'reaction_170', + 'reaction_171', 'reaction_172', 'reaction_173', 'reaction_174', + 'struggle', 'idle_alt', 'faint_alt', 'flinch', 'reaction_179', + 'reaction_180', 'reaction_181', 'reaction_182', 'entrance_alt', + 'idle_return', +] + +N_MOVES = 165 +NONE16 = 0xFFFF + +# How many battle Pokemon the model archive holds, and where the fixed context +# slots start in a species' battle table (entries 0..164 are the moves). +N_POKEMON = 151 +CTX_BASE = 165 + + +# --------------------------------------------------------------- bind extent + +def quat_basis(r): + """The game's rotation as a 3x3, rows first (src/F420.c func_8000F730). + + Rx*Ry*Rz in row-vector form -- the same basis model_extract/pipeline/ + glb.py converts to a quaternion, kept as a matrix here because that is + what the runtime builds too. + """ + sx, cx = math.sin(r[0] / 32768 * math.pi), math.cos(r[0] / 32768 * math.pi) + sy, cy = math.sin(r[1] / 32768 * math.pi), math.cos(r[1] / 32768 * math.pi) + sz, cz = math.sin(r[2] / 32768 * math.pi), math.cos(r[2] / 32768 * math.pi) + return ((cy * cz, sx * sy * cz - cx * sz, cx * sy * cz + sx * sz), + (cy * sz, sx * sy * sz + cx * cz, cx * sy * sz - sx * cz), + (-sy, sx * cy, cx * cy)) + + +def mat_mul(a, b): + """3x4 (rotation rows plus a translation column) times the same.""" + out = [] + for r in range(3): + row = [] + for c in range(3): + row.append(sum(a[r][k] * b[k][c] for k in range(3))) + row.append(sum(a[r][k] * b[k][3] for k in range(3)) + a[r][3]) + out.append(tuple(row)) + return tuple(out) + + +def rest_sample(bones): + def sample(i): + b = bones[i] + return b['t'], b['r'], b['s'] + return sample + + +def anim_sample(bones, anim, frame): + """The bone TRS this animation holds at `frame`, rest where it is silent.""" + tracks = anim['tracks'] + + def component(comps, i, fallback): + if comps is None: + return fallback + c = comps[i] + if isinstance(c, list): + return c[frame % len(c)] if c else fallback + return c + + def sample(i): + b = bones[i] + tr = tracks[i] if i < len(tracks) else None + if not tr: + return b['t'], b['r'], b['s'] + return ([component(tr.get('t'), k, b['t'][k]) for k in range(3)], + [component(tr.get('r'), k, b['r'][k]) for k in range(3)], + [component(tr.get('s'), k, b['s'][k]) for k in range(3)]) + return sample + + +def bind_matrices(bones, sample=None): + """Every bone's draw matrix at one instant, as 3x4 rows. + + The game keeps bone scale out of the matrix chain: it accumulates in its + own stack, a bone's local translation is pre-multiplied by the PARENT's + accumulated scale, and the bone's own accumulated scale is applied to the + finished matrix at draw time. This is that, and it is the same walk + lib/StadiumRig.lua does per frame. + + Two chains, and the distinction is the whole point: `pivot` is the + rotation/translation chain a CHILD inherits, and the draw matrix is that + with the bone's own accumulated scale applied on the right. Folding the + scale into the chain instead would apply every ancestor's scale twice -- + which is exactly the multiplicative propagation glTF has and the game + does not (see model_extract/README.md's two-node export). + """ + sample = sample or rest_sample(bones) + pivot, draw, acc = [], [], [] + for i, b in enumerate(bones): + bt, br, bs = sample(i) + p = b['parent'] + pa = acc[p] if p >= 0 else (1.0, 1.0, 1.0) + pm = pivot[p] if p >= 0 else ((1, 0, 0, 0), (0, 1, 0, 0), (0, 0, 1, 0)) + t = [bt[i2] * pa[i2] for i2 in range(3)] + rot = quat_basis(br) + local = tuple((rot[r][0], rot[r][1], rot[r][2], t[r]) for r in range(3)) + m = mat_mul(pm, local) + a = tuple(pa[i2] * bs[i2] for i2 in range(3)) + acc.append(a) + pivot.append(m) + # scale on the right: the bone's own space, so it cannot reach children + draw.append(tuple((m[r][0] * a[0], m[r][1] * a[1], m[r][2] * a[2], + m[r][3]) for r in range(3))) + return draw + + +def pose_box(data, mats): + """The axis-aligned box the whole model occupies under `mats`, in game + units after the model_root scale.""" + root = data['rootScale'][0] + lo = [1e30, 1e30, 1e30] + hi = [-1e30, -1e30, -1e30] + for prim in data['prims']: + pos, skin = prim['pos'], prim['skin'] + for i in range(len(skin)): + m = mats[skin[i]] + x, y, z = pos[i * 3], pos[i * 3 + 1], pos[i * 3 + 2] + for a in range(3): + v = (m[a][0] * x + m[a][1] * y + m[a][2] * z + m[a][3]) * root + lo[a] = min(lo[a], v) + hi[a] = max(hi[a], v) + return lo, hi + + +def stance(data): + """(height, floor, radius): how tall the mon is, where its lowest point + sits relative to the model's own origin, and how wide it is -- all in + game units after the model_root scale. + + Measured on the BIND POSE, which is the one pose in the set that can be + trusted for this. Two things recommend it. It reproduces the verified + glTF export bit for bit on all 151 species, so it is measuring the same + skeleton the reference implementation agreed with; and it is immune to + the animation quirks a handful of species carry (Exeggutor's idle throws + limbs hundreds of units off the body from frame 1 on, and Magmar's does + something similar) -- quirks that would otherwise decide how big every + OTHER frame of those species is drawn. + + The floor is the interesting number, and it reads cleanly: 119 of the + 151 sit within 5% of zero, which says the model origin IS where the game + stands a Pokemon on its field. Every species that does not is one that + hovers -- Zubat, Magnemite and Geodude float above their origin, + Tentacruel, Gastly, Haunter, Weezing and Zapdos hang below it. What the + mod does with that is a placement decision and lives in StadiumMon. + """ + lo, hi = pose_box(data, bind_matrices(data['bones'])) + if lo[0] > hi[0]: + return 0.0, 0.0, 0.0 + return hi[1] - lo[1], lo[1], max(hi[0] - lo[0], hi[2] - lo[2]) / 2 + + +def idle_is_broken(data, idle): + """Whether this species' standby loop is corrupt in the source data. + + A handful of species come out of the extraction with animations that + throw bones hundreds of units off the body -- Exeggutor, Tangela and + Magmar, whose channel streams the game's own index arithmetic evidently + reads differently from the way the exporter does. Played, they look + like a Pokemon coming apart; the mod would rather stand them still. + + The test is deliberately narrow, because "differs from the bind pose" is + NOT brokenness. It is asked only of the STANDBY loop, which is the one + animation that is supposed to stay where it is -- a faint is meant to + end far from the standing pose and an attack is meant to lunge -- and it + wants both a large size blow-up and real drift, or an enormous amount of + one. Dewgong is what calibrates it: its idle is 2.4x its own bind pose + because the BIND is the collapsed one, and it drifts barely at all, so + it must not be caught. + """ + if idle is None: + return False + bones = data['bones'] + lo, hi = pose_box(data, bind_matrices(bones)) + span = hi[1] - lo[1] + if span <= 0: + return False + worst_h, worst_drift = 1.0, 0.0 + for frame in range(0, idle['frames'], 3): + flo, fhi = pose_box(data, bind_matrices(bones, + anim_sample(bones, idle, frame))) + worst_h = max(worst_h, (fhi[1] - flo[1]) / span) + worst_drift = max(worst_drift, abs(flo[1] - lo[1]) / span, + abs(fhi[1] - hi[1]) / span) + return ((worst_h > 2.5 and worst_drift > 1.5) + or worst_drift > 2.0 or worst_h > 3.4) + + +# --------------------------------------------------------------------- write + +class Writer: + def __init__(self): + self.parts = [] + + def raw(self, b): + self.parts.append(b) + + def u8(self, v): + self.parts.append(struct.pack(' hi else v) + + +def fixed(v): + """16.16, which holds every bone scale in the set (-31 .. 100) exactly + enough that a rounded one is invisible.""" + return clamp(int(round(v * 65536)), -2**31, 2**31 - 1) + + +def write_track_component(w, values, kind): + """One component of one bone's t/r/s in one animation. + + `values` is the js payload's own shape: a bare number when the component + holds still for the whole animation, or one number a frame when it does + not. That fold is where most of the size saving is -- a bone that only + rotates costs two bytes for each of its six other components. + """ + array = isinstance(values, list) + w.u8(1 if array else 0) + seq = values if array else [values] + if kind == 's': + for v in seq: + w.i32(fixed(v)) + else: + for v in seq: + w.i16(int(round(v))) + + +def context_table(rows, n_anims): + """Which animation each fixed battle context slot resolves to. + + Entries 165 upward of the species' own battle table, in slot order, which + is exactly what CONTEXTS lists. An entry naming an animation the species + does not have is written as "none" rather than clamped: the mod would + rather fall back than play the wrong clip. + """ + ctx = [NONE16] * len(CONTEXTS) + for i in range(len(CONTEXTS)): + e = CTX_BASE + i + ai = rows[e][0] if e < len(rows) else None + if ai is not None and ai < n_anims: + ctx[i] = ai + return ctx + + +def pack(data, species, move_rows, ctx): + w = Writer() + bones, prims = data['bones'], data['prims'] + textures, anims, aux = data['textures'], data['anims'], data['auxAnims'] + + height, floor, radius = stance(data) + + idle_index = ctx[CONTEXTS.index('idle')] + idle = anims[idle_index] if idle_index != NONE16 else None + static = idle_is_broken(data, idle) + + w.raw(b'DSM3') + for v in (species, len(bones), len(prims), len(textures), len(anims), + len(aux)): + w.u16(v) + w.f32(data['rootScale'][0]) + # 1 = hold the bind pose, never play an animation (see idle_is_broken). + # Immediately after rootScale, which is where the format table above says + # it is and where lib/StadiumPack.lua reads it. + w.u8(1 if static else 0) + w.f32(height) + w.f32(floor) + w.f32(radius) + + rows = move_rows or [] + for m in range(N_MOVES): + row = rows[m] if m < len(rows) else None + w.u16(row[0] if row and row[0] < len(anims) else NONE16) + for m in range(N_MOVES): + row = rows[m] if m < len(rows) else None + w.i16(row[1] if row and 0 <= row[1] < len(aux) else -1) + for v in ctx: + w.u16(v) + + for b in bones: + w.i16(b['parent']) + for v in b['t']: + w.i16(round(v)) + for v in b['r']: + w.i16(v) + for v in b['s']: + w.i32(fixed(v)) + + for p in prims: + w.u16(p['tex']) + # the display list's own cull mode: 1024 is G_CULL_BACK + w.u8(1 if p.get('cull') else 0) + w.u8(1 if p.get('blend') == 'add' else 0) + w.i16(p.get('texAnim', -1)) + tex_map = p.get('texMap') or {} + w.u8(len(tex_map)) + for key, tex in sorted(tex_map.items(), key=lambda kv: int(kv[0])): + w.u8(int(key)) + w.u16(tex) + frames = p.get('fxFrames') or [] + w.u16(len(frames)) + for f in frames: + w.u16(f) + pos, uv, nrm, skin, idx = (p['pos'], p['uv'], p['nrm'], p['skin'], + p['idx']) + n = len(skin) + w.u16(n) + w.u16(len(idx)) + for i in range(n): + w.i16(pos[i * 3]) + w.i16(pos[i * 3 + 1]) + w.i16(pos[i * 3 + 2]) + # 1/512, which puts a texel of the largest texture in the set + # well inside a step and still reaches the +-32 the wrapped + # coordinates of some display lists run to + w.i16(round(uv[i * 2] * 512)) + w.i16(round(uv[i * 2 + 1] * 512)) + w.i8(round(nrm[i * 3] * 127)) + w.i8(round(nrm[i * 3 + 1] * 127)) + w.i8(round(nrm[i * 3 + 2] * 127)) + w.u8(skin[i]) + for v in idx: + w.u16(v) + + for t in textures: + rgba = t['rgba'] + w.u16(t['w']) + w.u16(t['h']) + w.u32(len(rgba)) + w.raw(rgba) + + for a in anims: + name = (a.get('name') or '')[:255].encode('utf8') + w.u8(len(name)) + w.raw(name) + w.u16(a['frames']) + w.u16(a.get('loopStart', 0)) + w.i16(a.get('aux', -1)) + tracks = a['tracks'] + for bi in range(len(bones)): + tr = tracks[bi] if bi < len(tracks) else None + if not tr: + w.u8(0) + continue + w.u8(1) + for key in ('t', 'r', 's'): + comps = tr.get(key) + if comps is None: + # a bone the animation leaves at its rest value for this + # path: written as three constants so the reader never + # has to branch on a missing path + rest = {'t': [0, 0, 0], 'r': [0, 0, 0], + 's': [1.0, 1.0, 1.0]}[key] + src = bones[bi][key] if key in bones[bi] else rest + comps = list(src) + for c in comps: + write_track_component(w, c, key) + + for a in aux: + w.u16(a['frames']) + w.u16(a.get('loopStart', 0)) + chans = a['channels'] + w.u16(len(chans)) + for ch in chans: + w.u16(len(ch)) + for v in ch: + w.u16(v) + + return w.bytes(), (height, floor, radius) + + +# ---------------------------------------------------------------------- main + +def build_one(blob, fileno, tables, moves): + """One model fragment -> the payload `pack` takes. + + The same three steps build.py takes for a Pokemon, in the same order: + parse the fragment, label the animations off the species' battle table, + then hang the generated fire/gas stand-ins on the bones the game's own + effect callbacks hang off. + """ + data = fragment.extract(blob, '%d.bin' % fileno, raw=True) + species = data['species'] + rows = tables.rows(species) + build.label_animations(data, rows, moves) + build.attach_effects(data, species, raw=True) + data['name'] = battle.SPECIES.get(species, '#%d' % species) + return data, species, rows + + +def main(argv): + args = {a.split('=')[0]: (a.split('=', 1)[1] if '=' in a else True) + for a in argv} + out = args.get('--out') or os.path.join(MOD, 'assets', 'stadium') + only = ({int(x) for x in args['--only'].split(',')} + if '--only' in args else None) + + rom_path = args.get('--rom') or build.find_rom() + if not rom_path or not os.path.exists(rom_path): + print('ROM not found. Put a Pokemon Stadium (US 1.0) ROM at\n %s\n' + 'or pass --rom=PATH.' + % os.path.join(build.BASEROMS, 'baserom.z64'), file=sys.stderr) + return 1 + + rom = rom_mod.Rom(rom_path) + if not rom.is_expected_us: + print('warning: md5 %s is not the expected US 1.0 ROM' % rom.md5, + file=sys.stderr) + blobs = rom_mod.pokemon_models(rom) + tables = battle.BattleTables(rom) + moves = battle.load_move_names(os.path.join(MOD, 'model_extract')) + + os.makedirs(out, exist_ok=True) + total, report, statics = 0, [], [] + for fileno in range(min(N_POKEMON, len(blobs))): + data, species, rows = build_one(blobs[fileno], fileno, tables, moves) + if only and species not in only: + continue + move_rows = [[rows[m][0], rows[m][1]] for m in range(N_MOVES)] + blob, extent = pack(data, species, move_rows, + context_table(rows, len(data['anims']))) + if blob[4 + 12 + 4] == 1: + statics.append(species) + path = os.path.join(out, '%03d.dsm' % species) + with open(path, 'wb') as fp: + fp.write(blob) + total += len(blob) + report.append((species, data['name'], len(blob), extent, + len(data['bones']), len(data['prims']), + len(data['anims']))) + + print('%d models, %.1f MB -> %s' % (len(report), total / 1e6, out)) + if statics: + print('held at the bind pose (corrupt standby loop in the source): %s' + % ', '.join(str(x) for x in statics)) + if '--report' in args: + report.sort(key=lambda r: -r[3][0]) + print('%-4s %-12s %9s %8s %8s %6s %6s %5s' + % ('#', 'name', 'bytes', 'height', 'floor', 'bones', 'prims', + 'anims')) + for r in report[:10] + report[-5:]: + print('%-4d %-12s %9d %8.2f %8.2f %6d %6d %5d' + % (r[0], r[1], r[2], r[3][0], r[3][1], r[4], r[5], r[6])) + heights = sorted(r[3][0] for r in report) + print('height: min %.2f median %.2f max %.2f' + % (heights[0], heights[len(heights) // 2], heights[-1])) + return 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:]))