diff --git a/CHANGELOG.md b/CHANGELOG.md index 094bb36..be3cb0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,9 +96,30 @@ mod ships no Pokemon Stadium data and cannot: it is that game's. What it ships is the READER. - Drop a Pokemon Stadium (US) ROM -- `.z64`, `.n64` or `.v64`, the byte - order is detected -- into a `baseroms/` folder beside the game, straight in - it rather than in a revision subfolder under it, and the first time it runs + **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 @@ -234,6 +255,28 @@ 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. +- **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 diff --git a/README.md b/README.md index 0eaecbb..f8aace7 100644 --- a/README.md +++ b/README.md @@ -124,14 +124,28 @@ under that cave's void and its own flat light. 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. -1. Put a **Pokémon Stadium (US)** ROM in a `baseroms/` folder beside the game - — straight in it, not in a subfolder. `.z64`, `.n64` and `.v64` all work; - the byte order is detected. - In a packaged build, `baseroms/` goes in the save directory; the mod logs - the exact path on startup when it cannot find one. -2. Start the game. The 151 models are built out of the ROM on a loading - screen that says so and shows a progress bar, in about ten seconds. -3. The two STADIUM rungs appear on the 3D-BTL row. +1. Open **OPTIONS** and press the **STADIUM ROM** row. It opens your system's + file picker; choose a **Pokémon Stadium (US)** 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 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 diff --git a/lib/Stadium.lua b/lib/Stadium.lua index 239e8f1..b574231 100644 --- a/lib/Stadium.lua +++ b/lib/Stadium.lua @@ -205,6 +205,38 @@ local function onField(battle, side, mon) 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 diff --git a/lib/StadiumInstall.lua b/lib/StadiumInstall.lua index 0bb4210..a161b40 100644 --- a/lib/StadiumInstall.lua +++ b/lib/StadiumInstall.lua @@ -196,8 +196,8 @@ local function writePack(species, bytes) return true end --- Open the ROM and start a stepped build. Returns false plus a reason when --- there is nothing to build from. +-- 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 @@ -208,6 +208,23 @@ function StadiumInstall.begin() 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") @@ -215,8 +232,30 @@ function StadiumInstall.begin() if not rom then return false, tostring(err) end if not rom:isExpectedUS() then V.mod.log:warn("stadium: %s is md5 %s, not the US 1.0 ROM the model " - .. "offsets are keyed to -- building anyway", path, - tostring(rom:md5())) + .. "offsets are keyed to -- building anyway", + tostring(label or "the ROM"), tostring(rom: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, "that is not a Pokemon Stadium ROM" end pcall(f.createDirectory, StadiumInstall.DIR) @@ -243,7 +282,12 @@ function StadiumInstall.step() end if not more then local f = fs() - local wrote = #job.failed == 0 + -- `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, @@ -253,8 +297,18 @@ function StadiumInstall.step() end if not wrote then status.state = "failed" - status.error = ("%d of %d models could not be built") - :format(#job.failed, job.total) + -- 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 = "that is not a Pokemon Stadium ROM" + else + status.error = ("%d of %d models could not be built") + :format(#job.failed, job.total) + end else status.state = "done" end diff --git a/lib/StadiumScreen.lua b/lib/StadiumScreen.lua index bfeee92..9ea6c94 100644 --- a/lib/StadiumScreen.lua +++ b/lib/StadiumScreen.lua @@ -68,6 +68,34 @@ local function centred(str, y) 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"). +local COLS = 20 + +-- Break a reason into lines that fit, on word boundaries, and never more than +-- `limit` of them -- the plate has room for two and a message nobody can read +-- the end of is not improved by adding a third. +local function wrapped(str, limit) + local lines, line = {}, nil + 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 lines[#lines + 1] = line end + -- a single word too long to fit is cut rather than dropped: it is + -- usually a filename, and its first twenty characters still identify it + line = (#word <= COLS) and word or word:sub(1, COLS) + end + if #lines >= (limit or 2) then break end + end + if line and #lines < (limit or 2) then lines[#lines + 1] = 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 @@ -91,8 +119,16 @@ local function speciesName(dex) return dexNames[dex] end -function StadiumScreen.new(game) - return setmetatable({ game = game, hold = 0, started = false }, StadiumScreen) +-- `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 -- Opaque: the loading screen owns the frame, so the map underneath is not @@ -100,6 +136,7 @@ end StadiumScreen.isOpaque = true function StadiumScreen:enter() + if self.adopted then return end local ok, err = StadiumInstall.begin() self.started = ok and true or false if not ok then @@ -156,10 +193,10 @@ function StadiumScreen:draw() centred("OF STADIUM ASSETS", 46) if status.state == "failed" then - centred("COULD NOT BUILD", 70) - local why = tostring(status.error or "unknown"):sub(1, 24) - centred(why, 84) - centred("STADIUM IS OFF", 104) + 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 @@ -226,9 +263,21 @@ function StadiumScreen.maybePush() -- 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. + local okPick, pick = pcall(V.require, "StadiumRomPick") + local canPick = okPick and pick and pick.available() V.mod.log:info("stadium: no Pokemon Stadium ROM found, so the STADIUM " - .. "battle rungs are off. Put one (.z64/.n64/.v64) in " - .. "%s and restart.", StadiumInstall.romHint()) + .. "battle rungs are off. %s", + canPick + and ("Import one from OPTIONS -> " .. pick.LABEL + .. ", or put a .z64/.n64/.v64 in " + .. StadiumInstall.romHint() .. " and restart.") + or ("Put one (.z64/.n64/.v64) in " + .. StadiumInstall.romHint() .. " and restart.")) end return false end diff --git a/main.lua b/main.lua index af08d7d..6b9e33e 100644 --- a/main.lua +++ b/main.lua @@ -795,6 +795,15 @@ 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. + 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 37c5cae..93c8f8c 100644 --- a/manifest.json +++ b/manifest.json @@ -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. The 3D-BTL row is five rungs across two choices: 2D-3D stands the game's own battle pics up as cards and STADIUM replaces them with the Pokemon Stadium battle models -- 148 of the 151 species, animated, playing the animation the move being used calls for -- while A stages the fight on the map and B on two carried discs against the sky, which works on every map including the ones with nowhere to put a fight. The STADIUM rungs need a Pokemon Stadium (US) ROM of your own dropped in a baseroms/ folder, and build themselves out of it on first run. 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": "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. The 3D-BTL row is five rungs across two choices: 2D-3D stands the game's own battle pics up as cards and STADIUM replaces them with the Pokemon Stadium battle models -- 148 of the 151 species, animated, playing the animation the move being used calls for -- while A stages the fight on the map and B on two carried discs against the sky, which works on every map including the ones with nowhere to put a fight. The STADIUM rungs need a Pokemon Stadium (US) ROM of your own: import it with the system file picker from the STADIUM ROM row on the OPTIONS menu, or drop it in a baseroms/ folder, and the models build themselves out of it on a loading screen. 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.", "github": "DramaticShape/DramaticShapeVoxelMod" } diff --git a/mod.card b/mod.card index 414fbc2..cb472c3 100644 --- a/mod.card +++ b/mod.card @@ -52,7 +52,7 @@ 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. Drop a Pokemon Stadium (US) ROM in a baseroms/ folder beside the game and the 151 models are built out of it on a loading screen the first time it runs, in about ten seconds, into the save directory; 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 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 a Pokemon Stadium (US) ROM in a baseroms/ folder beside the game; 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", diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index f99b8f6..54cd770 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -1136,6 +1136,20 @@ Game.keypressed(keyGame, "7") T.neq(Curve.setting:get(), curveBefore, "7 cycles V-CURVE") local Battles = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle") + +-- 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") @@ -1156,6 +1170,69 @@ 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.available()) == "boolean", + "the picker reports whether this platform has a file dialog at all") + + local row = Pick.row() + if not Pick.available() then + T.eq(row, nil, + "with no dialog available there is no row -- a button that cannot open " + .. "anything is worse than the folder instruction it would replace") + else + T.check(row ~= nil, "and where there is one, there is a row") + T.eq(row.label, "STADIUM ROM", "which says what it is for") + T.check(type(row.step) == "function", "and does something when pressed") + -- the value is the STATE, so a player can see whether it worked + T.eq(row.value(), Install.available() and "READY" or "IMPORT", + "reading READY once the models are installed and IMPORT before that") + end + + -- 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 @@ -1222,6 +1299,7 @@ end)() -- 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 @@ -1292,6 +1370,7 @@ end)() -- 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 @@ -1372,6 +1451,29 @@ end)() .. "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 @@ -1407,9 +1509,15 @@ end)() Battles.setting.index = 3 T.eq(Battles.setting:get(), true, "a stored STADIUM with no models behind it reads as 2D-3D A") - Battles.setting:setGate(gate) + -- 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)() diff --git a/tests/stadium_shots.lua b/tests/stadium_shots.lua index fae53da..203c026 100644 --- a/tests/stadium_shots.lua +++ b/tests/stadium_shots.lua @@ -182,6 +182,74 @@ return function(game) 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