Compare commits

..

12 Commits

Author SHA1 Message Date
DramaticShape 6bcd4d3144 Merge pull request #118 from DramaticShape/dev
1.6.2
2026-08-06 01:17:21 -04:00
DramaticShape b50bbe0782 Merge pull request #111 from Code-Grub/fix/roof-surface-silhouette-cap
keep a sloped roof's surface off its own outline cap
2026-08-06 01:13:19 -04:00
DramaticShape 9f74ea7e73 Merge pull request #117 from DramaticShape/stadium_fixes
Update viridan forest FX
2026-08-06 01:12:06 -04:00
DramaticShape 442e9d26d5 add fog/god rays to viridian forest 2026-08-06 01:07:38 -04:00
DramaticShape 6240b50cec update all battle locations 2026-08-05 20:36:46 -04:00
DramaticShape 790c34efff Merge pull request #115 from DramaticShape/dev
1.6.1
2026-08-05 19:11:33 -04:00
DramaticShape e14cf3de90 Merge pull request #114 from DramaticShape/stadium_fixes
Stadium fixes
2026-08-05 19:10:17 -04:00
DramaticShape 77e0f93315 update compatibility 2026-08-05 19:03:49 -04:00
DramaticShape 20c9061625 Fix rendering for broken mons 2026-08-05 18:30:41 -04:00
Code-Grub dde0879527 keep a sloped roof's surface off its own outline cap
The hip ends of the gabled house and Oak's lab come out as black teeth
marching down the slope instead of battens running with it.

The depth->row map is authored for columns the drawing paints edge to
edge. A tapered column starts further down the band, and the surface was
clamped into its first DRAWN row to stay inside the silhouette -- but a
column's first drawn row is its silhouette CAP, and the cap is outline
black. On a tapered column the map spends most of the roof's depth above
that cap (seven of the gabled house's fifty depth voxels land on it
outright), so the clamp painted one outline pixel the length of the
slope and roofCycle beat against it: black punctuated by the real batten
colour every 4 rows on the house, every 8 on the lab.

Lift to the column's first PAINTED row instead -- the same refusal to
let outline black stand as a face that measure() already makes for the
side faces, which the roof band was never given.

Geometry is untouched: voxel, shell and recess counts are identical for
all 50 templates. verify_roof now asserts no roof surface samples its
column's cap, and tests/roof_cap_shots.lua shoots the two Pallet Town
drawings A/B.

The version bump and CHANGELOG entry are left out deliberately, so this
does not collide with the release flow.
2026-08-05 17:45:56 -04:00
DramaticShape 9e54656fe3 Merge pull request #82 from luisgonzaleznf/fix/water-shader-effect-precision-love12
fix: water shader under LÖVE 12 — make effect()'s parameter precision a define
2026-08-05 17:45:41 -04:00
luisgonzaleznf c6b38f8d44 fix water shader under LOVE 12: make effect()'s parameter precision a define
The float params were pinned to mediump to match LOVE 11's forward
declaration of effect(), because a definition whose precisions differ
from the prototype's reads as a second function to some compilers.  LOVE
12 declares it under a different precision, so the pin became the
mismatch there and the shader stopped compiling -- lakes drew flat on
any LOVE 12 + Metal build, iOS included.

The qualifier is a define now, and Water.shader compiles the pinned form
first and the bare one only if that is refused, so whichever prototype a
runtime brought, one of the two agrees with it.  The warning fires only
when both are refused.
2026-08-03 15:14:04 +02:00
33 changed files with 3878 additions and 219 deletions
+2
View File
@@ -4,6 +4,8 @@
# The SDK suite and the probes it grew out of. A shipped test that requires
# an engine module reads as a private require against the archive
# (CONTRIBUTING-mods.md "What the PR must contain", 2).
tests/arena_config.lua
tests/arena_editor.lua
tests/arena_pick.lua
tests/battle_shots.lua
tests/dramatic_shape_test.lua
+52
View File
@@ -1,5 +1,57 @@
# Changelog
## 1.6.2
### Added
- **The air of Viridian Forest: volumetric god rays, ground fog, and a
FOREST FX row.** An invisible jungle canopy now hangs above the forest's
real trees, and light comes down through it as true volumetric beams: a
per-pixel march reads the frame's own depth buffer and the sun's own
shadow map, so shafts stand exactly where light really breaks between
the tree hulls, trunks and passing characters carve dark columns
through them, and a wind-blown leaf field at the canopy plane opens and
closes the beams like foliage moving overhead. The beams are alpha zero
at the canopy and fade in as they descend -- light below the leaves,
never a lid above them -- and a forward-scattering term blooms them for
a camera looking up into the light, first person especially.
The scene shader gains a height-and-distance fog every surface sinks
into, and the rays are that fog lit: one shared ramp off the day/night
clock colours both, gold spears of sun by day, silver moon rays after
dark, dying back through the twilights as one hands over to the other.
Pollen drifts through the day's beams and fireflies blink low over the
floor at night, all shader-animated and deterministic. A fight staged
on the forest floor sits in the same haze at half density.
The direction never moves: a canopy map's light is pinned to noon (see
DayNight.CANOPY), so the beams always agree with the shadows on the
floor. Everything is authored per map in `data/map_atmosphere.lua` --
a map with no entry spends nothing -- and the **FOREST FX** row (FULL /
LOW / OFF, FULL by default) governs the cost: LOW halves the march and
stands the particles down. On Android the row offers LOW / OFF only --
no mobile driver grants the readable depth the march needs -- so the
forest keeps its haze there and loses the beams.
## 1.6.1
### Fixed
- **Exeggutor, Tangela and Magmar stand as models in STADIUM battles, and
Pidgeot and Dodrio stop animating garbled.** Five species animate with
hermite keyframes rather than packed per-frame streams, and the extractor
read the animation flags byte from the wrong half of its u16 -- the half
that is always zero -- so it decoded their keyframe tables as streams.
For Exeggutor, Tangela and Magmar the result exploded so hard the packer
declined them to flat battle pics; Pidgeot and Dodrio stayed models but
played the garbage. All five now decode the way the game's own sampler
(src/17300.c) does, and no species is held off the field any more.
Model packs built by an older version of the mod are detected by a
revision stamp in the install marker and rebuilt from the ROM on the next
launch (or shadowed by a checkout's freshly packed set) rather than
trusted.
## 1.6.0
### Added
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 DramaticShape
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-2
View File
@@ -213,8 +213,6 @@ alongside.
## Licenses
This mod is released under the **MIT License** — see [`LICENSE`](LICENSE).
It redistributes one third-party binary:
- **`assets/vr/openxr_loader.dll`** — the Khronos OpenXR loader
+88 -64
View File
@@ -23,6 +23,20 @@
--
-- SHOT_DIR=.scratchpad/arenas \
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_pick.lua love .
--
-- To change ONE map's spot, or to go over these by eye, run the editor
-- instead -- it is the same choice made in front of the map rather than in a
-- batch. It slides the arena around on a plan of the map with the fit, the
-- clearance and the camera's own sightlines answering live, stages a real
-- battle on the spot when asked, and writes this file back a map at a time,
-- replacing only the lines that changed and leaving every comment here alone:
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_editor.lua lovec .
--
-- Its export lands in .scratchpad/arena_editor/ to be diffed and copied over;
-- ARENA_WRITE=1 points it at this file directly. A comment above an entry it
-- moved describes where that spot USED to be, and it says so by name at
-- export time -- those are the lines to re-word by hand.
-- `cam = "wide"` on an entry swaps the long default lens for the 44-degree
-- one (BattleCam.RIGS). Both frame the same composition -- the two mons land
@@ -49,14 +63,14 @@ return {
-- height along the sightline and a hedge in the apron row is not terrain.
-- So the choice is where in the clearing, and this is its west end: tree
-- line square behind the pair, nothing crossing either of them.
["ROUTE_1"] = { x = 4, y = 14, shape = "narrow" },
["ROUTE_1"] = { x = 10, y = 16, shape = "narrow", turn = 90 },
["ROUTE_2"] = { x = 1, y = 49, shape = "wide" },
["ROUTE_3"] = { x = 57, y = 1, shape = "wide" },
["ROUTE_4"] = { x = 46, y = 7, shape = "wide" },
["ROUTE_5"] = { x = 13, y = 24, shape = "wide" },
["ROUTE_6"] = { x = 5, y = 17, shape = "narrow" },
["ROUTE_7"] = { x = 8, y = 8, shape = "narrow" },
["ROUTE_8"] = { x = 25, y = 7, shape = "wide" },
["ROUTE_3"] = { x = 52, y = 10, shape = "wide", turn = 90 },
["ROUTE_4"] = { x = 46, y = 4, shape = "wide" },
["ROUTE_5"] = { x = 7, y = 0, shape = "wide", turn = 270 },
["ROUTE_6"] = { x = 4, y = 15, shape = "narrow" },
["ROUTE_7"] = { x = 4, y = 3, shape = "narrow", cam = "wide" },
["ROUTE_8"] = { x = 24, y = 5, shape = "wide", turn = 90 },
-- the whole route admits six bare wide arenas, all in the west cliff
-- corridor; this is the best of them. A flower cluster crosses the far
-- mon's hind legs, which the brief allows -- every alternative put a
@@ -64,13 +78,13 @@ return {
["ROUTE_9"] = { x = 1, y = 11, shape = "wide", cam = "wide" },
["ROUTE_10"] = { x = 7, y = 40, shape = "wide" },
["ROUTE_11"] = { x = 9, y = 6, shape = "wide" },
["ROUTE_12"] = { x = 0, y = 73, shape = "wide" },
["ROUTE_13"] = { x = 50, y = 8, shape = "narrow" },
["ROUTE_14"] = { x = 11, y = 25, shape = "wide" },
["ROUTE_15"] = { x = 9, y = 10, shape = "wide" },
["ROUTE_16"] = { x = 6, y = 10, shape = "wide" },
["ROUTE_12"] = { x = 10, y = 71, shape = "wide", turn = 90 },
["ROUTE_13"] = { x = 51, y = 5, shape = "narrow" },
["ROUTE_14"] = { x = 11, y = 24, shape = "wide" },
["ROUTE_15"] = { x = 30, y = 10, shape = "wide", turn = 90 },
["ROUTE_16"] = { x = 8, y = 10, shape = "wide", turn = 90 },
["ROUTE_17"] = { x = 14, y = 70, shape = "wide" },
["ROUTE_18"] = { x = 11, y = 4, shape = "wide" },
["ROUTE_18"] = { x = 11, y = 3, shape = "wide" },
-- ------- buildings and caves
--
@@ -78,10 +92,10 @@ return {
-- or gravestones in it rarely holds a 3x6 clearing whose whole width is
-- also SEEN, and giving up the apron is usually the difference between a
-- fight in the open and one behind a console.
["POKEMON_MANSION_1F"] = { x = 4, y = 12, shape = "wide" },
["POKEMON_MANSION_2F"] = { x = 15, y = 17, shape = "wide" },
["POKEMON_MANSION_3F"] = { x = 23, y = 2, shape = "narrow", cam = "wide" },
["POKEMON_MANSION_B1F"] = { x = 19, y = 10, shape = "wide" },
["POKEMON_MANSION_1F"] = { x = 4, y = 12, shape = "wide", cam = "wide" },
["POKEMON_MANSION_2F"] = { x = 10, y = 1, shape = "wide", cam = "wide" },
["POKEMON_MANSION_3F"] = { x = 24, y = 3, shape = "narrow", cam = "wide" },
["POKEMON_MANSION_B1F"] = { x = 6, y = 19, shape = "wide", turn = 90 },
["POKEMON_TOWER_2F"] = { x = 4, y = 7, shape = "narrow" },
["POKEMON_TOWER_3F"] = { x = 4, y = 6, shape = "wide" },
-- every one of 4F's thirty candidate spots puts a gravestone through a
@@ -89,73 +103,79 @@ return {
-- is shot there
["POKEMON_TOWER_4F"] = { map = "POKEMON_TOWER_3F", x = 4, y = 6,
shape = "wide" },
["POKEMON_TOWER_5F"] = { x = 10, y = 1, shape = "narrow" },
["POKEMON_TOWER_6F"] = { x = 14, y = 6, shape = "narrow" },
["POKEMON_TOWER_5F"] = { x = 8, y = 9, shape = "narrow", cam = "wide" },
["POKEMON_TOWER_6F"] = { x = 14, y = 6, shape = "narrow", cam = "wide" },
["POKEMON_TOWER_7F"] = { x = 9, y = 5, shape = "wide" },
["POWER_PLANT"] = { x = 18, y = 5, shape = "narrow" },
["ROCK_TUNNEL_1F"] = { x = 14, y = 15, shape = "wide" },
["ROCK_TUNNEL_B1F"] = { x = 20, y = 17, shape = "wide" },
["ROCKET_HIDEOUT_B1F"] = { x = 11, y = 6, shape = "narrow" },
["ROCKET_HIDEOUT_B2F"] = { x = 19, y = 7, shape = "narrow" },
["ROCKET_HIDEOUT_B3F"] = { x = 22, y = 11, shape = "narrow" },
["ROCKET_HIDEOUT_B4F"] = { x = 17, y = 3, shape = "narrow" },
["ROCKET_HIDEOUT_B1F"] = { x = 20, y = 18, shape = "narrow" },
["ROCKET_HIDEOUT_B2F"] = { x = 20, y = 10, shape = "narrow", cam = "wide" },
["ROCKET_HIDEOUT_B3F"] = { x = 24, y = 15,
shape = "narrow", turn = 90, cam = "wide" },
["ROCKET_HIDEOUT_B4F"] = { x = 19, y = 17, shape = "wide", cam = "wide" },
["SAFARI_ZONE_CENTER"] = { x = 1, y = 8, shape = "wide" },
["SAFARI_ZONE_EAST"] = { x = 21, y = 8, shape = "wide" },
["SAFARI_ZONE_NORTH"] = { x = 19, y = 14, shape = "wide" },
["SAFARI_ZONE_WEST"] = { x = 18, y = 3, shape = "wide" },
["SAFARI_ZONE_EAST"] = { x = 16, y = 8,
shape = "wide", turn = 90, cam = "wide" },
["SAFARI_ZONE_NORTH"] = { x = 22, y = 12, shape = "wide", turn = 90 },
["SAFARI_ZONE_WEST"] = { x = 20, y = 2, shape = "wide" },
["SEAFOAM_ISLANDS_1F"] = { x = 14, y = 7, shape = "wide" },
["SEAFOAM_ISLANDS_B1F"] = { x = 11, y = 1, shape = "wide" },
["SEAFOAM_ISLANDS_B2F"] = { x = 16, y = 2, shape = "wide" },
["SEAFOAM_ISLANDS_B3F"] = { x = 25, y = 7, shape = "wide" },
["SEAFOAM_ISLANDS_B4F"] = { x = 12, y = 6, shape = "narrow" },
["SEAFOAM_ISLANDS_B1F"] = { x = 9, y = 8,
shape = "wide", turn = 90, cam = "wide" },
["SEAFOAM_ISLANDS_B2F"] = { x = 15, y = 9, shape = "wide", turn = 90 },
["SEAFOAM_ISLANDS_B3F"] = { x = 26, y = 7, shape = "wide", turn = 180 },
["SEAFOAM_ISLANDS_B4F"] = { x = 9, y = 7, shape = "narrow" },
-- Silph Co is office floors partitioned into small rooms, so the long lens
-- often lands outside the walls it is meant to be looking between; the
-- floors that could not be framed any other way ask for the wide one.
["SILPH_CO_2F"] = { x = 16, y = 8, shape = "narrow" },
["SILPH_CO_4F"] = { x = 24, y = 2, shape = "narrow" },
["SILPH_CO_2F"] = { x = 4, y = 9, shape = "narrow", turn = 270 },
["SILPH_CO_4F"] = { x = 14, y = 14, shape = "narrow", turn = 90 },
["SILPH_CO_5F"] = { x = 16, y = 7, shape = "wide" },
["SILPH_CO_6F"] = { x = 10, y = 8, shape = "narrow" },
["SILPH_CO_6F"] = { x = 19, y = 2,
shape = "narrow", turn = 90, cam = "wide" },
["SILPH_CO_7F"] = { x = 1, y = 2, shape = "wide", cam = "wide" },
["SILPH_CO_8F"] = { x = 8, y = 6, shape = "narrow" },
["SILPH_CO_9F"] = { x = 20, y = 11, shape = "wide", cam = "wide" },
["SS_ANNE_1F_ROOMS"] = { x = 10, y = 1, shape = "narrow", cam = "wide" },
["SS_ANNE_1F_ROOMS"] = { x = 11, y = 1, shape = "narrow", cam = "wide" },
-- the ship is all two-cell corridors, so the wide arena shape fits nowhere
-- aboard and the long lens always lands outside the hull
["SS_ANNE_2F"] = { x = 36, y = 8, shape = "narrow", cam = "wide" },
-- these two decks are byte-identical geometry, so they take the same spot
["SS_ANNE_2F_ROOMS"] = { x = 11, y = 12, shape = "narrow" },
["SS_ANNE_B1F_ROOMS"] = { x = 11, y = 12, shape = "narrow" },
["SS_ANNE_2F_ROOMS"] = { x = 11, y = 12, shape = "narrow", cam = "wide" },
["SS_ANNE_B1F_ROOMS"] = { x = 11, y = 12, shape = "narrow", cam = "wide" },
["SS_ANNE_BOW"] = { x = 8, y = 3, shape = "wide" },
["VICTORY_ROAD_2F"] = { x = 16, y = 6, shape = "wide" },
["VICTORY_ROAD_3F"] = { x = 20, y = 1, shape = "wide" },
["VICTORY_ROAD_2F"] = { x = 16, y = 6, shape = "wide", cam = "wide" },
["VICTORY_ROAD_3F"] = { x = 20, y = 1, shape = "wide", cam = "wide" },
-- ------- towns and the last interiors
["CERULEAN_CITY"] = { x = 15, y = 16, shape = "wide" },
["GAME_CORNER"] = { x = 8, y = 7, shape = "wide" },
["GAME_CORNER"] = { x = 8, y = 5, shape = "wide" },
-- the lab is ten cells by twelve, so the long lens is always off-map, and
-- on it a desk clipped one mon and a pillar the other
["OAKS_LAB"] = { x = 3, y = 2, shape = "narrow", cam = "wide" },
["OAKS_LAB"] = { x = 1, y = 3, shape = "narrow", turn = 90, cam = "wide" },
-- Saffron's gym is a grid of small walled cells: no wide shape exists
-- anywhere in it, and the long lens sits inside a divider
["SAFFRON_GYM"] = { x = 9, y = 7, shape = "narrow", cam = "wide" },
["SILPH_CO_3F"] = { x = 18, y = 11, shape = "wide", cam = "wide" },
["SILPH_CO_10F"] = { x = 1, y = 2, shape = "wide" },
["SILPH_CO_11F"] = { x = 1, y = 11, shape = "wide", cam = "wide" },
["SILPH_CO_10F"] = { x = 1, y = 1, shape = "wide", cam = "wide" },
["SILPH_CO_11F"] = { x = 10, y = 6,
shape = "wide", turn = 180, cam = "wide" },
-- the upper corridor (cols 4-5, rows 1-4) is sealed at runtime by the
-- gym's barrier, so arenas there silently fail the fit test
["VERMILION_GYM"] = { x = 4, y = 11, shape = "narrow" },
["VICTORY_ROAD_1F"] = { x = 11, y = 2, shape = "narrow" },
["VERMILION_GYM"] = { x = 3, y = 2,
shape = "narrow", turn = 90, cam = "wide" },
["VICTORY_ROAD_1F"] = { x = 11, y = 5, shape = "narrow", cam = "wide" },
["VIRIDIAN_FOREST"] = { x = 16, y = 34, shape = "narrow" },
-- ------- the remaining routes
["ROUTE_19"] = { x = 8, y = 6, shape = "narrow" },
["ROUTE_19"] = { x = 8, y = 31, shape = "narrow" },
-- the two surf routes fight AFLOAT, in the middle of their own sea rather
-- than on the rim of beach the land search would otherwise find
["ROUTE_20"] = { x = 23, y = 7, shape = "wide" },
["ROUTE_21"] = { x = 8, y = 46, shape = "wide" },
["ROUTE_22"] = { x = 35, y = 7, shape = "wide" },
["ROUTE_23"] = { x = 4, y = 36, shape = "wide" },
["ROUTE_24"] = { x = 13, y = 15, shape = "wide" },
["ROUTE_22"] = { x = 35, y = 7, shape = "wide", cam = "wide" },
["ROUTE_23"] = { x = 2, y = 34, shape = "wide", cam = "wide" },
["ROUTE_24"] = { x = 6, y = 9, shape = "wide", turn = 270, cam = "wide" },
["ROUTE_25"] = { x = 32, y = 2, shape = "wide", cam = "wide" },
-- ------- caves, gyms and the Elite Four
@@ -163,25 +183,29 @@ return {
-- None of these tilesets has a grass tile at all, so the no-grass rule
-- constrained nothing here; what constrains them is furniture, rock
-- pillars and how small the rooms are.
["AGATHAS_ROOM"] = { x = 2, y = 1, shape = "narrow", cam = "wide" },
["BRUNOS_ROOM"] = { x = 3, y = 1, shape = "narrow" },
["CELADON_GYM"] = { x = 0, y = 3, shape = "narrow" },
["CERULEAN_CAVE_1F"] = { x = 1, y = 7, shape = "narrow" },
["AGATHAS_ROOM"] = { x = 4, y = 2, shape = "narrow", cam = "wide" },
["BRUNOS_ROOM"] = { x = 2, y = 1, shape = "wide", turn = 90 },
["CELADON_GYM"] = { x = 3, y = 4,
shape = "narrow", turn = 90, cam = "wide" },
["CERULEAN_CAVE_1F"] = { x = 12, y = 8, shape = "narrow", cam = "wide" },
-- 2F is a maze of one-cell rock corridors; all 24 of its candidate spots
-- hide a mon, so it borrows the floor below -- the same cave
["CERULEAN_CAVE_2F"] = { map = "CERULEAN_CAVE_B1F", x = 2, y = 0,
shape = "wide" },
["CERULEAN_CAVE_B1F"] = { x = 2, y = 0, shape = "wide" },
["CERULEAN_GYM"] = { x = 0, y = 1, shape = "narrow" },
["CERULEAN_CAVE_B1F"] = { x = 2, y = 0, shape = "wide", cam = "wide" },
["CERULEAN_GYM"] = { x = 4, y = 2, shape = "wide" },
["CHAMPIONS_ROOM"] = { x = 2, y = 2, shape = "narrow", cam = "wide" },
["CINNABAR_GYM"] = { x = 18, y = 10, shape = "narrow" },
["DIGLETTS_CAVE"] = { x = 19, y = 16, shape = "wide" },
["FIGHTING_DOJO"] = { x = 4, y = 1, shape = "narrow" },
["LANCES_ROOM"] = { x = 5, y = 15, shape = "wide" },
["LORELEIS_ROOM"] = { x = 5, y = 2, shape = "narrow" },
["MT_MOON_1F"] = { x = 24, y = 17, shape = "wide" },
["MT_MOON_B1F"] = { x = 5, y = 12, shape = "wide" },
["MT_MOON_B2F"] = { x = 2, y = 16, shape = "wide" },
["CINNABAR_GYM"] = { x = 9, y = 16,
shape = "narrow", turn = 90, cam = "wide" },
["DIGLETTS_CAVE"] = { x = 14, y = 26,
shape = "wide", turn = 90, cam = "wide" },
["FIGHTING_DOJO"] = { x = 3, y = 5,
shape = "narrow", turn = 90, cam = "wide" },
["LANCES_ROOM"] = { x = 4, y = 2, shape = "wide", cam = "wide" },
["LORELEIS_ROOM"] = { x = 4, y = 2, shape = "narrow" },
["MT_MOON_1F"] = { x = 25, y = 16, shape = "wide" },
["MT_MOON_B1F"] = { x = 4, y = 11, shape = "wide" },
["MT_MOON_B2F"] = { x = 8, y = 20, shape = "wide" },
-- The three gyms the default rig cannot stand back from. Five blocks is
-- further than these rooms are wide, so the eye landed outside the map and
@@ -190,7 +214,7 @@ return {
-- BattleCam), which fits inside the room; the mons come out smaller and all
-- three became stageable. It is asked for HERE, per map, so every area that
-- does not ask keeps the long lens it was framed for.
["FUCHSIA_GYM"] = { x = 7, y = 6, shape = "narrow", cam = "wide" },
["PEWTER_GYM"] = { x = 4, y = 8, shape = "narrow", cam = "wide" },
["FUCHSIA_GYM"] = { x = 8, y = 6, shape = "narrow", cam = "wide" },
["PEWTER_GYM"] = { x = 4, y = 1, shape = "narrow", turn = 90, cam = "wide" },
["VIRIDIAN_GYM"] = { x = 10, y = 8, shape = "narrow", cam = "wide" },
}
+63
View File
@@ -0,0 +1,63 @@
-- What hangs in the air of each map.
--
-- One entry per map that has an ATMOSPHERE: a ground fog the scene shader
-- folds every surface into, and volumetric god rays -- light let down
-- through an INVISIBLE canopy hanging above the map's real geometry, as
-- if the trees drawn are only the understorey of something taller. The
-- rays are not placed: a per-pixel march (see ForestAtmos) reads the
-- frame's own depth and the sun's own shadow map, so the beams stand
-- exactly where light really breaks between the tree hulls, trees and
-- characters carve dark columns through them, and a wind-blown leaf
-- field at the canopy plane opens and closes them like foliage moving
-- overhead. The light leans along the mod's fixed noon shear (the one
-- light a canopy map ever gets -- see DayNight.CANOPY); only its COLOUR
-- and STRENGTH follow the clock: gold spears of sun by day, silver moon
-- rays after dark, pollen adrift in the day's beams and fireflies once
-- they cool.
--
-- A map with no entry here has no atmosphere at all: no fog uniform is
-- raised, no march runs, nothing is spent. That is the contract a new
-- map opts into by adding a line, and what a stale entry degrades to if
-- its map id ever stops existing.
--
-- The knobs, in world pixels unless said otherwise (a map cell is 16):
--
-- canopyY where the invisible canopy hangs. MUST clear the tallest
-- real geometry under it (Viridian's carved tree hulls top
-- at y = 32) -- a beam is alpha ZERO at this height and only
-- fades in below it, so a canopy at or under the tree tops
-- would cut every ray off before it cleared the leaves.
-- fadeTo the height by which a descending ray reaches full strength.
-- fog density how fast distance dissolves into the haze
-- (1 - exp(-density * distance-past-start))
-- start how many pixels out the dissolve begins
-- heightK how quickly the fog thins with ALTITUDE
-- (exp(-y * heightK): 0.02 halves it by y = 35)
-- rays strength overall in-scatter gain on the march
-- reach how far out the march walks, in world px
-- motes count of pollen/dust flecks adrift in the daylight beams
-- fireflies count of the night shift
-- seed the xorshift seed the particle deal runs on
--
-- Two caveats for maps opting in later: the water pass has no fog term,
-- so a lake under heavy haze stays clear-day sharp in its reflections;
-- and the march runs after the water's mirror copy, so beams will not
-- appear IN those reflections either. Neither can bite in a map without
-- water.
return {
["VIRIDIAN_FOREST"] = {
canopyY = 56,
fadeTo = 28,
fog = { density = 0.0045, start = 64, heightK = 0.02 },
-- strength is calibrated against the march's real integral: the
-- under-canopy stretch of an orbit ray is short and thinly dense, so
-- the raw accumulation for a fully lit beam core is a few percent --
-- this gain lands it near +0.3 on screen. Halve it for a whisper,
-- double it for cathedral light.
rays = { strength = 16, reach = 380 },
motes = { count = 96 },
fireflies = { count = 48 },
seed = 0x51D,
},
}
+72 -8
View File
@@ -109,6 +109,57 @@ BattleArena.SHAPES = {
{ id = "narrow", w = 1, h = 4, enemy = { 0, 0 }, player = { 0, 3 } },
}
-- ------- which way round the fight stands
--
-- Both shapes above are drawn north-south, with the foe at the top and the
-- player below it, and the camera is solved for that: it sits off the
-- player's shoulder, low and back down the arena's own axis. `turn` swings
-- the WHOLE staging a quarter at a time -- the footprint, the two cells, and
-- the camera with them -- so the composition on screen is identical and only
-- the ground under it is different.
--
-- It buys two things.
--
-- A footprint that FITS. The wide shape is three cells by six; an east-west
-- corridor two cells deep has no room for it standing up and all the room in
-- the world for it lying down. Half the maps in Kanto run the other way from
-- the one shape this mode was drawn in.
--
-- And a BACKDROP. A quarter turn moves the camera to a different side of the
-- same patch of ground, so the wall behind the pair becomes the window
-- behind them, or the cliff becomes the valley. Nothing about the shot's
-- geometry changes -- the mons land on the same two screen anchors at the
-- same size -- so this is purely a choice about what is behind them, made
-- per map by somebody looking at it.
--
-- Written in DEGREES in data/battle_arenas.lua (`turn = 90`) because that is
-- what it is; handled as quarter turns everywhere below.
local function quarters(turn)
local q = math.floor(((tonumber(turn) or 0) / 90) + 0.5)
return ((q % 4) + 4) % 4
end
BattleArena.quarters = quarters
-- The footprint a shape covers once turned: a quarter or three of a turn
-- swaps how far it reaches in each direction, which is the whole reason a
-- corridor takes one and not the other.
function BattleArena.extent(shape, turn)
if quarters(turn) % 2 == 1 then return shape.h, shape.w end
return shape.w, shape.h
end
-- Where a cell offset inside the shape ends up under the same turn, measured
-- from the turned footprint's own north-west corner -- so the corner an entry
-- names stays the corner, whichever way the fight faces from it.
local function spin(shape, turn, ox, oy)
local q = quarters(turn)
if q == 1 then return shape.h - 1 - oy, ox end
if q == 2 then return shape.w - 1 - ox, shape.h - 1 - oy end
if q == 3 then return oy, shape.w - 1 - ox end
return ox, oy
end
-- Whether a cell is open ground for the purpose above.
--
-- "Open" is the walk test the player themselves answer to, so an arena can
@@ -174,12 +225,19 @@ end
-- Build the record the renderer reads: the two mons' cells and, in world
-- pixels, the centre of each and of the pair.
local function place(shape, x, y)
local ex, ey = x + shape.enemy[1], y + shape.enemy[2]
local px, py = x + shape.player[1], y + shape.player[2]
local function place(shape, x, y, turn)
local eox, eoy = spin(shape, turn, shape.enemy[1], shape.enemy[2])
local pox, poy = spin(shape, turn, shape.player[1], shape.player[2])
local ex, ey = x + eox, y + eoy
local px, py = x + pox, y + poy
local w, h = BattleArena.extent(shape, turn)
local arena = {
shape = shape.id,
x = x, y = y, w = shape.w, h = shape.h,
-- carried in degrees, so everything downstream that reasons about the
-- shot -- the camera's base yaw above all -- reads the same number the
-- data file was written with
turn = quarters(turn) * 90,
x = x, y = y, w = w, h = h,
enemyCell = { ex, ey },
playerCell = { px, py },
-- world-pixel centres of the two cells a mon stands on
@@ -302,8 +360,12 @@ function BattleArena.find(map, fromX, fromY, surfing)
-- ocean rather than on a scrap of beach at the edge of the map. Land
-- entries are unaffected: land passes the test either way.
local grid, gw = openGrid(host, true)
if fits(grid, gw, pick.x, pick.y, shape.w, shape.h) then
local arena = place(shape, pick.x, pick.y)
-- measured against the TURNED footprint: an entry that lies the arena
-- down an east-west corridor covers different ground from the one that
-- stands it up, and the fit test is the thing that has to know
local fw, fh = BattleArena.extent(shape, pick.turn)
if fits(grid, gw, pick.x, pick.y, fw, fh) then
local arena = place(shape, pick.x, pick.y, pick.turn)
arena.map = host
-- which camera rig this spot is framed for; nil is the default long
-- lens, "close" the short one small rooms need (see BattleCam)
@@ -321,9 +383,11 @@ end
-- The arena at a given north-west corner, whatever the map says about it.
-- The authoring tool's manual override: a spot chosen by eye rather than by
-- the search, so it can be photographed and judged before it is written down.
function BattleArena.at(x, y, shapeId)
function BattleArena.at(x, y, shapeId, turn)
for _, shape in ipairs(BattleArena.SHAPES) do
if shape.id == (shapeId or "wide") then return place(shape, x, y) end
if shape.id == (shapeId or "wide") then
return place(shape, x, y, turn)
end
end
return nil
end
+21 -2
View File
@@ -431,7 +431,22 @@ function BattleCam.rig(arena, groundY, canonical)
-- arena's own axis, and the room the player has is all on the far side of
-- that -- out toward square-on. (orbitRange measures exactly that room.)
local steer = steered and -BattleCam.orbit * BattleCam.orbitRange(arena) or 0
local yaw = steer + (fixed and 0
-- ------- and the quarter turn the arena itself is standing at
--
-- An arena may be laid down any of the four ways (BattleArena's `turn`),
-- and the rig is solved for ONE of them: eye off the player's shoulder,
-- back down an axis that runs north-south. So the whole offset is turned
-- with the ground under it, which leaves the camera in exactly the same
-- place RELATIVE to the two mons -- same distance, same height, same
-- angle -- and therefore lands them on the same two screen anchors at the
-- same size. A turn is a fact about the map, never about the shot.
--
-- It goes in with the drift and the steer rather than beside them because
-- it is the same rotation about the same point; the player's own orbit is
-- then measured from wherever the arena starts, so both stops travel with
-- it and side-on stays side-on.
local base = math.rad(arena.turn or 0)
local yaw = base + steer + (fixed and 0
or BattleCam.PAN_YAW * phase(BattleCam.t, BattleCam.PAN_PERIOD))
local c, s = math.cos(yaw), math.sin(yaw)
-- the breath scales the whole offset, height included, so the eye moves
@@ -443,7 +458,11 @@ function BattleCam.rig(arena, groundY, canonical)
local dz = (R.side * s + R.back * c) * k
local eye = { mx + dx, groundY + R.height * k, mz + dz }
local focus = { mx + R.lookX, groundY + R.lookY, mz }
-- the aim's own offset turns with the arena too, and with the BASE alone --
-- the drift and the steer swing the eye about the focus, so a focus that
-- followed them would take the thing being orbited around with it
local bc, bs = math.cos(base), math.sin(base)
local focus = { mx + R.lookX * bc, groundY + R.lookY, mz + R.lookX * bs }
-- and the climb: the eye swung UP about the focus, at a constant radius.
-- About the focus so the aim stays nailed to the two mons and only the
+15
View File
@@ -303,7 +303,11 @@ end
-- first drawn in.
local function shadowSignature(state, arena, terrain, nbMesh, token)
local host = arena.map or state.map
-- `turn` is in the signature with the corner and the shape: the same corner
-- turned a quarter is a different footprint standing on different ground,
-- and a cast kept from the other one freezes the shadows across it
local parts = { "battle", host.id, arena.x, arena.y, arena.shape,
tostring(arena.turn or 0),
tostring(terrain), tostring(token or 0),
-- the cycle keeps running through a fight, and an arena lit
-- from somewhere new must be re-cast from there
@@ -475,6 +479,17 @@ function BattleScene.render(state, arena, textures, token)
-- no glint in the arena: the drift is the shot breathing, not the player
-- moving, and a shimmer on background windows would fight the mons
Voxel3D.glassGlint = 0
-- the host floor's atmosphere reaches the staged shot at HALF density --
-- a fight in Viridian Forest sits in the same haze the walk there did,
-- thinned so neither mon goes soft -- and its god rays stay out of it:
-- this camera is low and long, and a bright blade across a combatant
-- reads as a rendering fault, not weather. nil almost everywhere.
local ForestAtmos = V.require("ForestAtmos")
local atmos = ForestAtmos.frame(host)
Voxel3D.fog = atmos and { color = atmos.fog.color,
density = atmos.fog.density * 0.5,
start = atmos.fog.start,
heightK = atmos.fog.heightK } or nil
-- 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 --
+28 -5
View File
@@ -264,6 +264,27 @@ local function measure(sp, t)
top[x] = r
end
-- The row a column's roof SURFACE may sink to. `top[x]` is the
-- silhouette cap -- the black the drawing closes its shape with -- and
-- the depth map spends most of a tapered column's depth above it, so
-- clamping onto `top[x]` paints that one outline pixel the length of
-- the slope and the courses beat against it. The surface belongs on the
-- first PAINTED row instead: the same refusal to let the outline stand
-- as a face that the side faces already make below.
local surfaceTop = {}
for x = 0, W - 1 do
local y = top[x]
while y < roofRows and sp.inside[y * W + x]
and sp.col[y * W + x] == BLACK do
y = y + 1
end
if y < roofRows and sp.inside[y * W + x] then
surfaceTop[x] = y
else
surfaceTop[x] = top[x]
end
end
-- The drawing's own ground line: the row after the last drawn one. A
-- building ends on the black threshold row it stands on (ground == H),
-- but furniture is drawn standing on open floor -- the lab table's
@@ -386,7 +407,7 @@ local function measure(sp, t)
-- building. `depthPx` names it in voxels, for an object whose real
-- depth is not a whole tile row -- the Bike Shop toolbox is a box
-- standing in the middle of its own cell, not a thing that fills a plot.
return { top = top, ytop = ytop,
return { top = top, surfaceTop = surfaceTop, ytop = ytop,
D = t.depthPx or ((t.depth or #t.tiles) * 8),
ground = ground,
recess = recess, interior = interior, shadeTexel = shadeTexel }
@@ -882,6 +903,7 @@ local function model(sp, pr, t)
local W, H, D = sp.W, sp.H, pr.D
local slab, roofRows = t.slab, t.roofRows
local top, ytop, ground = pr.top, pr.ytop, pr.ground
local surfaceTop = pr.surfaceTop
-- The roof's drawn span. A sprite inset from its box (B03) leaves outer
-- columns undrawn in the roof band; they carry no roof at all, and the
@@ -931,11 +953,12 @@ local function model(sp, pr, t)
if top[x] < roofRows
and y > tx - slab and y <= tx and z >= rz0 and z <= rz1 then
if y == tx and x > x0d and x < x1d and z > rz0 and z < rz1 then
-- the surface itself. Clamping the row into the column's first
-- drawn row keeps the flank battens running down the slope
-- instead of falling off the silhouette.
-- the surface itself. Lifting the row into the column's first
-- PAINTED row keeps the flank battens running down the slope
-- instead of falling off the silhouette -- and off its cap, which
-- is outline black and belongs to the rim, not to the surface.
local sy = roofSy[z]
if sy < top[x] then sy = top[x] end
if sy < surfaceTop[x] then sy = surfaceTop[x] end
return sy * W + x
end
-- The rim reproduces the eave the drawing itself paints under the
+822
View File
@@ -0,0 +1,822 @@
-- The air under the canopy: fog, god rays, and what drifts through them.
--
-- Some maps have an ATMOSPHERE (data/map_atmosphere.lua -- Viridian Forest
-- today, any map that adds a line tomorrow): a ground haze the scene shader
-- folds every surface into (see Voxel3D.fog), and VOLUMETRIC light let down
-- through an INVISIBLE canopy hanging above the map's real trees, as if the
-- carved hulls on screen were only the understorey of something taller.
--
-- The rays are not placed geometry. A fullscreen pass marches every
-- pixel's eye ray through the air, stops at the frame's own depth buffer
-- (the same detach-and-read Water runs), and asks two questions of every
-- step of air on the way:
--
-- * the SUN'S question -- the shadow map. Air behind a tree hull is
-- dark air; air in a real gap glows. A trunk stands in a column of
-- its own shade, a character walks through the beams and blocks
-- them, and every shaft on screen agrees with the light already on
-- the floor, because it is read from the same map.
--
-- * the CANOPY'S question -- where this thread of sun pierced the
-- invisible leaf layer. Every step of air on one sun ray shares that
-- point, which is what makes a shaft a SHAFT, and the point samples
-- a wind-blown noise field: the dapple drifts and shivers like
-- leaves moving overhead, opening and closing the beams as it goes.
--
-- The shafts lean along the fixed noon shear, deliberately: a canopy
-- map's rig is pinned to noon (see DayNight.CANOPY), so light that
-- followed the sun's arc would part company with every shadow on the
-- floor. What follows the clock is colour and strength -- gold spears of
-- sun by day, silver moon rays after dark, dying back through the
-- twilights -- plus the crew each shift brings: pollen adrift in the
-- day's beams, fireflies once they cool. A forward-scattering phase term
-- brightens the beams for a camera looking up into the light, which is
-- most of what makes them read as light in air rather than paint on it.
--
-- Everything is deterministic: placement and the leaf field are dealt by
-- a seeded xorshift (StadiumFx's generator), motion is a pure function
-- of one `time` uniform, so a pinned ForestAtmos.time reproduces a frame
-- exactly (see tests/forest_fog_shots). Every shader compiles lazily
-- behind pcall -- nil untried, false unavailable -- and each refusal
-- subtracts only itself: no march without readable depth, no beams
-- without a shadow map, and the fog rides the scene shader whatever
-- happens here.
local V = ...
local DayNight = V.require("DayNight")
local ModSetting = V.require("ModSetting")
local floor, sqrt, min, max = math.floor, math.sqrt, math.min, math.max
local ForestAtmos = {}
-- FULL is the point; LOW halves the march and drops the particles, for
-- hardware that minds a per-pixel loop under 4X supersampling.
--
-- On ANDROID the ladder itself is shorter: LOW and OFF, with LOW the
-- default. The march needs a depth texture it can READ, and no driver on
-- the phones this runs on has granted one (see newDepth in Voxel3D) --
-- so FULL would be a rung with nothing behind it, which reads as a
-- broken mod rather than a missing feature. LOW there is the haze, the
-- one part of the atmosphere that rides the scene shader and works
-- everywhere. A desktop save opened on a phone stores FULL still;
-- ModSetting's unknown-value fallback lands it on LOW, and putting the
-- save back on the desktop restores the choice.
local function onAndroid()
if not (love and love.system and love.system.getOS) then return false end
local ok, os = pcall(love.system.getOS)
return ok and os == "Android"
end
ForestAtmos.setting = onAndroid()
and ModSetting.new("atmos", "FOREST FX", { "low", "off" },
{ "LOW", "OFF" })
or ModSetting.new("atmos", "FOREST FX", { "full", "low", "off" },
{ "FULL", "LOW", "OFF" })
-- the animation clock: ticked by main.lua's always-running update hook,
-- pinnable (frozen = true) so a screenshot driver can hold a frame still
ForestAtmos.time = 0
ForestAtmos.frozen = false
function ForestAtmos.update(dt)
if ForestAtmos.frozen then return end
ForestAtmos.time = ForestAtmos.time + (dt or 0)
end
-- ------- the authored table
--
-- Same shape as BattleArena's: the data file behind a pcall with a false
-- sentinel, and an overrides table a tuning driver can stage a candidate
-- through before anything is written down. `~= nil` on the override,
-- because false is meaningful -- "this map has no atmosphere, whatever
-- the file says".
local authored = nil
local overrides = {}
local function configFor(mapId)
if not mapId then return nil end
local forced = overrides[mapId]
if forced ~= nil then return forced or nil end
if authored == nil then
local ok, list = pcall(V.data, "map_atmosphere")
authored = (ok and type(list) == "table") and list or false
end
if not authored then return nil end
return authored[mapId]
end
ForestAtmos.configFor = configFor
-- ------- caches
--
-- Particle layouts and meshes go stale with the map (map.reloaded, and
-- the pipeline's invalidate); called with no map id this also resets the
-- shader and texture sentinels, which is what a lost GL context needs.
local layoutCache = {}
local meshCache = {}
local shaders = {} -- keyed by variant; nil untried, false refused
local leafTex = nil -- the tiling leaf-dapple field
local rayMesh = nil -- the fullscreen ray-fan quad, re-aimed per draw
-- Every bail here is deliberate and silent on screen -- a missing piece
-- subtracts itself, never the frame -- but "the beams are off" and "the
-- beams are off BECAUSE ..." are different debugging days. Each reason
-- is said once on the console, the way VR reports a missing runtime.
local said = {}
local function say(key, msg)
if said[key] then return end
said[key] = true
print("[DRAMATIC_SHAPE] atmos: " .. msg)
end
function ForestAtmos.invalidate(mapId)
if mapId then
layoutCache[mapId] = nil
meshCache[mapId] = nil
else
layoutCache, meshCache = {}, {}
shaders = {}
leafTex = nil
rayMesh = nil
end
end
function ForestAtmos.setOverride(mapId, entry)
overrides[mapId] = entry
ForestAtmos.invalidate(mapId)
end
-- ------- the hour's answer
--
-- One ramp, authored per phase and blended with DayNight's own weights,
-- so the fog and the rays can never disagree about what hour it is. The
-- two interact three ways: the fog colour leans toward the ray colour
-- (noon warms the haze, midnight silvers it), the rays scale with the
-- fog's density (a beam IS lit fog -- less medium, less beam), and the
-- march accumulates through the same density the surfaces sink into.
ForestAtmos.RAMP = {
day = { fog = { 0.78, 0.86, 0.70 }, ray = { 1.00, 0.93, 0.70 },
alpha = 0.55, density = 1.00, motes = 1.0, flies = 0.0 },
golden = { fog = { 0.84, 0.76, 0.58 }, ray = { 1.00, 0.85, 0.55 },
alpha = 0.35, density = 1.00, motes = 0.6, flies = 0.0 },
dawn = { fog = { 0.80, 0.70, 0.66 }, ray = { 1.00, 0.80, 0.62 },
alpha = 0.20, density = 1.05, motes = 0.3, flies = 0.25 },
dusk = { fog = { 0.78, 0.66, 0.58 }, ray = { 1.00, 0.76, 0.55 },
alpha = 0.20, density = 1.05, motes = 0.2, flies = 0.5 },
violet = { fog = { 0.52, 0.50, 0.66 }, ray = { 0.82, 0.80, 1.00 },
alpha = 0.25, density = 1.10, motes = 0.0, flies = 1.0 },
night = { fog = { 0.34, 0.40, 0.56 }, ray = { 0.72, 0.80, 1.00 },
alpha = 0.40, density = 1.15, motes = 0.0, flies = 1.0 },
}
-- The frame's atmosphere for `map` at clock `t` (defaulting to now), or
-- nil -- no entry, or the row is OFF -- in which case nothing is drawn
-- and Voxel3D.fog should be left nil.
function ForestAtmos.frame(map, t)
if ForestAtmos.setting:get() == "off" then return nil end
local cfg = configFor(map and map.id)
if not cfg then return nil end
local mix = DayNight.mix(t or DayNight.time())
local fr, fg, fb, rr, rg, rb = 0, 0, 0, 0, 0, 0
local alpha, dens, motes, flies = 0, 0, 0, 0
for name, w in pairs(mix) do
local p = ForestAtmos.RAMP[name] or ForestAtmos.RAMP.day
fr, fg, fb = fr + p.fog[1] * w, fg + p.fog[2] * w, fb + p.fog[3] * w
rr, rg, rb = rr + p.ray[1] * w, rg + p.ray[2] * w, rb + p.ray[3] * w
alpha = alpha + p.alpha * w
dens = dens + p.density * w
motes = motes + p.motes * w
flies = flies + p.flies * w
end
-- the haze takes on a little of the light standing in it
local LEAN = 0.15
fr = fr + (rr - fr) * LEAN
fg = fg + (rg - fg) * LEAN
fb = fb + (rb - fb) * LEAN
local base = cfg.fog or {}
local rays = cfg.rays or {}
return {
-- in exactly the shape Voxel3D.fog takes, so callers assign it whole
fog = { color = { fr, fg, fb },
density = (base.density or 0) * dens,
start = base.start or 0,
heightK = base.heightK or 0 },
rayColor = { rr, rg, rb },
-- a beam is scattered fog: less medium, less beam
rayAlpha = alpha * (0.4 + 0.6 * min(dens, 1)),
rayStrength = rays.strength or 12,
rayReach = rays.reach or 380,
moteLevel = motes,
fireflyLevel = flies,
cfg = cfg,
}
end
-- ------- deterministic noise
--
-- The same written-out xorshift StadiumFx runs (see the note there on why
-- not LuaJIT's `bit`): the particle deal and the leaf field must come out
-- identical on every machine and every visit.
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
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
-- bilinear value noise on a torus (StadiumFx's), so the leaf field tiles
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 smoothstep01(t)
return t * t * (3 - 2 * t)
end
local function torus(grid, w, h, x, y)
local x0, y0 = floor(x), floor(y)
local fx, fy = smoothstep01(x - x0), smoothstep01(y - y0)
local x1, y1 = (x0 + 1) % w, (y0 + 1) % h
x0, y0 = x0 % w, y0 % h
local a = grid[y0 + 1][x0 + 1]
local b = grid[y0 + 1][x1 + 1]
local c = grid[y1 + 1][x0 + 1]
local d = grid[y1 + 1][x1 + 1]
return (a + (b - a) * fx) + ((c + (d - c) * fx) - (a + (b - a) * fx)) * fy
end
-- ------- the leaf field
--
-- One small tiling texture of three-octave value noise, generated once
-- from a fixed seed: the pattern of the unseen foliage. The shader reads
-- it at two drifting, differently-scaled offsets and thresholds the sum,
-- so the pools of light between the leaves slide, open and close -- the
-- movement is the WIND's, all in the sampling; the cloth itself never
-- changes, which is what keeps a pinned frame reproducible.
local function leafTexture()
if leafTex ~= nil then return leafTex or nil end
if not (love and love.image and love.image.newImageData
and love.graphics and love.graphics.newImage) then
leafTex = false
return nil
end
local ok, tex = pcall(function()
local N = 128
local rng = newRng(0x1EAF)
local g1 = lattice(rng, 8, 8)
local g2 = lattice(rng, 16, 16)
local g3 = lattice(rng, 32, 32)
local img = love.image.newImageData(N, N)
for y = 0, N - 1 do
local v = y / N
for x = 0, N - 1 do
local u = x / N
local n = torus(g1, 8, 8, u * 8, v * 8) * 0.5
+ torus(g2, 16, 16, u * 16, v * 16) * 0.3
+ torus(g3, 32, 32, u * 32, v * 32) * 0.2
img:setPixel(x, y, n, n, n, 1)
end
end
local t = love.graphics.newImage(img)
t:setWrap("repeat", "repeat")
t:setFilter("linear", "linear")
return t
end)
leafTex = (ok and tex) or false
return leafTex or nil
end
-- ------- placement (the particles; the light places itself)
local MARGIN = 24 -- keep off the map's edge, world px
function ForestAtmos.layout(cfg, w, h)
local rng = newRng((cfg.seed or 0x51D))
local canopy = cfg.canopyY or 56
local motes = {}
for _ = 1, (cfg.motes and cfg.motes.count) or 0 do
motes[#motes + 1] = {
x = MARGIN + rng:unit() * max(w - 2 * MARGIN, 1),
y = 3 + rng:unit() * max(canopy - 11, 8),
z = MARGIN + rng:unit() * max(h - 2 * MARGIN, 1),
phase = rng:unit() * 6.2832,
rate = 0.5 + rng:unit(),
}
end
local flies = {}
for _ = 1, (cfg.fireflies and cfg.fireflies.count) or 0 do
flies[#flies + 1] = {
x = MARGIN + rng:unit() * max(w - 2 * MARGIN, 1),
y = 3 + rng:unit() * 12,
z = MARGIN + rng:unit() * max(h - 2 * MARGIN, 1),
phase = rng:unit() * 6.2832,
rate = 0.5 + rng:unit(),
}
end
return { motes = motes, flies = flies }
end
-- A map is width x height BLOCKS of 4x4 tiles of 8 pixels -- times 32
-- for world pixels (the same arithmetic Structures runs in tiles).
local function layoutFor(map)
local hit = layoutCache[map.id]
if hit ~= nil then return hit or nil end
local cfg = configFor(map.id)
if not cfg then
layoutCache[map.id] = false
return nil
end
local def = map.def or {}
local L = ForestAtmos.layout(cfg, (def.width or 16) * 32,
(def.height or 16) * 32)
layoutCache[map.id] = L
return L
end
ForestAtmos.layoutFor = layoutFor
-- ------- the volumetric march
--
-- A fullscreen quad whose four corners carry the camera's own frustum
-- rays; the varying interpolates them into a world ray per pixel. The
-- pixel stage walks that ray to the depth buffer's surface, and every
-- step of air on the way is lit or not by the shadow map and the leaf
-- field, accumulated through the same haze the surfaces sink into.
--
-- Conventions copied from Water's march: the ray walks the FLAT world
-- (the space it is straight in) and every depth compare bends the sample
-- first, by the same displacement the vertex stage applies -- so the
-- march reads the depth buffer it actually has. The shadow lookup stays
-- flat, exactly like the scene shader's own vSun. STEPS is spliced into
-- the source rather than sent (the LOW rung is a second compile), and
-- there are no uniform arrays anywhere -- see the note in Sky about the
-- Android driver that reads them as zero.
local RAY_SHADER = [[
varying vec3 vRay;
#ifdef VERTEX
attribute vec3 RayDir;
vec4 position(mat4 transform_projection, vec4 vertex_position) {
vRay = RayDir;
return transform_projection * vertex_position;
}
#endif
#ifdef PIXEL
uniform Image depthTex; // the frame's own depth, detached to read
uniform Image sunMap; // the sun's answer (see ShadowMap)
uniform Image leafTex; // the unseen foliage, tiling
uniform mat4 vp;
uniform mat4 sunVP;
uniform float sunBias;
uniform vec3 eye;
uniform vec3 curve; // xy = the focus in world XZ, z = k; 0 = off
uniform vec2 screen; // canvas size, for the pixel's own uv
uniform vec4 fogW; // density, heightK, canopyY, fadeTo
uniform vec3 shear; // the noon shear kx, kz; z = reach
uniform vec3 rayColor;
uniform float strength;
uniform vec3 sunward; // unit, toward the unseen sun
uniform vec2 wind; // leaf-field drift, uv per second
uniform float time;
float sunDepth(vec2 uv) {
vec4 c = Texel(sunMap, uv);
return c.r + c.g * (1.0 / 255.0);
}
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec2 uv = sc / screen;
float sceneD = Texel(depthTex, uv).r;
vec3 dir = normalize(vRay);
// Spend every sample where a sample can glow. Above the canopy no
// beam exists, and below the floor there is no air at all -- so the
// march runs from where this ray first dips under the leaves to
// where it would pass the ground, however long or short that
// stretch is. From the orbit camera that is the last few dozen
// pixels of a mostly-vertical ray, and dividing the WHOLE reach by
// the step count there starved the beams to nothing.
float t0 = 0.0;
if (eye.y > fogW.z) {
if (dir.y >= -0.01) return vec4(0.0);
t0 = (eye.y - fogW.z) / -dir.y;
}
float tEnd = shear.z;
if (dir.y < -0.01) {
tEnd = min(tEnd, (eye.y + 8.0) / -dir.y);
}
if (tEnd <= t0) return vec4(0.0);
// interleaved gradient noise staggers neighbouring pixels' steps,
// which is what turns 20-odd samples into a smooth volume instead
// of an onion of banded slices
float jitter = fract(52.9829189
* fract(dot(sc, vec2(0.06711056, 0.00583715))));
float dt = (tEnd - t0) / float(STEPS);
// HALF the fog's own extinction, on the way in and per step: the
// full rate is what the surfaces sink by, and beams that obeyed it
// too died before the orbit camera ever saw them. Half keeps the
// depth cue and leaves the light alive.
float trans = exp(-fogW.x * 0.5 * t0);
float acc = 0.0;
for (int i = 0; i < STEPS; i++) {
float t = t0 + (float(i) + jitter) * dt;
vec3 p = eye + dir * t;
// stop at the surface: bend the sample the way the geometry bent
vec2 cd = p.xz - curve.xy;
vec4 c = vp * vec4(p.x, p.y - dot(cd, cd) * curve.z, p.z, 1.0);
if (c.w <= 1e-6) break;
if (c.z / c.w * 0.5 + 0.5 > sceneD) break;
if (p.y < fogW.z) {
// the sun's question: is this air behind a tree? Outside the
// frustum nothing was recorded and the air counts as lit, eased
// at the rim exactly like the scene shader's shadows
float lit = 1.0;
vec3 su = (sunVP * vec4(p, 1.0)).xyz;
if (su.x > 0.0 && su.x < 1.0 && su.y > 0.0 && su.y < 1.0
&& su.z < 1.0) {
vec2 e2 = min(su.xy, 1.0 - su.xy);
float edge = smoothstep(0.0, 0.06, min(e2.x, e2.y));
lit = mix(1.0, step(su.z - sunBias, sunDepth(su.xy)), edge);
}
// the canopy's question: where did this thread of light pierce
// the leaves? Every step of air on one sun ray shares the
// answer -- that shared point is what makes a shaft a shaft --
// and the two drifting reads of the field are the wind moving
// the foliage overhead, opening and closing the beams
float up = fogW.z - p.y;
vec2 gap = (p.xz - shear.xy * up) * (1.0 / 96.0);
float n = Texel(leafTex, gap + wind * time).r * 0.65
+ Texel(leafTex, gap * 2.3 - wind * (time * 0.7)
+ vec2(0.37, 0.61)).r * 0.35;
float dapple = 0.08 + 0.92 * smoothstep(0.45, 0.85, n);
// the beam fades IN below the invisible canopy, thins with
// altitude like the haze it is made of, and kisses the floor
float y = max(p.y, 0.0);
float fadeIn = clamp(up / max(fogW.z - fogW.w, 1.0), 0.0, 1.0);
float foot = 0.55 + 0.45 * clamp(y / 16.0, 0.0, 1.0);
float dens = fogW.x * exp(-y * fogW.y);
acc += trans * lit * dapple * fadeIn * foot * dens * dt;
}
trans *= exp(-fogW.x * 0.5 * dt);
}
// forward scattering: beams bloom for a camera looking up into the
// light, which is most of what makes them read as light IN air
float phase = 0.35 + 0.65 * pow(max(dot(dir, sunward), 0.0), 6.0);
return vec4(rayColor * (acc * strength * phase), 1.0) * color;
}
#endif
]]
local function rayShaderFor(steps)
local key = "ray" .. steps
local s = shaders[key]
if s ~= nil then return s or nil end
if not (love and love.graphics and love.graphics.newShader) then
shaders[key] = false
return nil
end
local src = "#define STEPS " .. steps .. "\n" .. RAY_SHADER
local ok, sh = pcall(love.graphics.newShader, src)
if not ok then
say(key, "ray shader refused -- beams off, fog stays: "
.. tostring(sh))
end
shaders[key] = (ok and sh) or false
return shaders[key] or nil
end
local RAY_FORMAT = {
{ "VertexPosition", "float", 2 },
{ "RayDir", "float", 3 },
}
-- The camera's frustum corners, from the same fields every pass sets:
-- eye, focus, fovY, and the placed camera's up when there is one (VR
-- eyes roll; the orbit never does). Interpolating a corner ray across
-- the quad IS the standard reconstruction for a perspective camera, so
-- this works identically for the orbit, first person and both eyes.
local function rayQuad(Voxel3D, w, h)
local e, fo, fov = Voxel3D.eye, Voxel3D.focus, Voxel3D.fovY
if not (e and fo and fov) then return nil end
local fx, fy, fz = fo[1] - e[1], fo[2] - e[2], fo[3] - e[3]
local fl = sqrt(fx * fx + fy * fy + fz * fz)
if fl < 1e-6 then return nil end
fx, fy, fz = fx / fl, fy / fl, fz / fl
local cam = Voxel3D.camera
local up = (cam and cam.up) or { 0, 1, 0 }
-- right = forward x up, then a true up perpendicular to both
local rx = fy * up[3] - fz * up[2]
local ry = fz * up[1] - fx * up[3]
local rz = fx * up[2] - fy * up[1]
local rl = sqrt(rx * rx + ry * ry + rz * rz)
if rl < 1e-6 then return nil end
rx, ry, rz = rx / rl, ry / rl, rz / rl
local ux = ry * fz - rz * fy
local uy = rz * fx - rx * fz
local uz = rx * fy - ry * fx
local hh = math.tan(fov * 0.5)
local hw = hh * (w / h)
-- canvas row 0 is the TOP of the frame, which is the +up corner
local function corner(su, sv)
return fx + rx * hw * su + ux * hh * sv,
fy + ry * hw * su + uy * hh * sv,
fz + rz * hw * su + uz * hh * sv
end
local x0, y0, z0 = corner(-1, 1)
local x1, y1, z1 = corner(1, 1)
local x2, y2, z2 = corner(1, -1)
local x3, y3, z3 = corner(-1, -1)
local verts = {
{ 0, 0, x0, y0, z0 },
{ w, 0, x1, y1, z1 },
{ w, h, x2, y2, z2 },
{ 0, h, x3, y3, z3 },
}
if not rayMesh then
local ok, mesh = pcall(love.graphics.newMesh, RAY_FORMAT, verts,
"fan", "stream")
rayMesh = ok and mesh or nil
return rayMesh
end
local ok = pcall(rayMesh.setVertices, rayMesh, verts)
return ok and rayMesh or nil
end
-- ------- the particles
local PART_SHADER = [[
varying vec2 vCorner;
varying float vGlow;
#ifdef VERTEX
uniform mat4 vp;
uniform vec3 curve;
uniform vec3 axisR; // the camera's right, world space
uniform vec3 axisU; // and its up: the billboard's own frame
uniform float time;
uniform float size;
uniform vec2 sway; // wander amplitude: horizontal, vertical
uniform float blinky; // 0 = steady motes, 1 = blinking fireflies
attribute vec4 AtmosData; // corner x, corner y, phase, rate
vec4 position(mat4 transform_projection, vec4 vertex_position) {
float ph = AtmosData.z;
float rt = AtmosData.w;
float t = time * (0.5 + rt);
// bounded wander only -- three incommensurate sines, so nothing ever
// walks off the map or needs a CPU tick to bring it home
vec3 base = vertex_position.xyz + vec3(
sin(t * 0.23 + ph) * sway.x,
sin(t * 0.17 + ph * 2.7) * sway.y,
cos(t * 0.19 + ph * 1.3) * sway.x);
float s = 0.5 + 0.5 * sin(t * 1.6 + ph * 9.0);
vGlow = mix(1.0, smoothstep(0.35, 0.75, s), blinky);
vCorner = AtmosData.xy;
vec4 w = vec4(base + axisR * (AtmosData.x * size)
+ axisU * (AtmosData.y * size), 1.0);
if (curve.z > 0.0) {
vec2 cd = w.xz - curve.xy;
w.y -= dot(cd, cd) * curve.z;
}
return vp * w;
}
#endif
#ifdef PIXEL
uniform vec3 dotColor;
uniform float level;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
float d = dot(vCorner, vCorner);
float glow = max(0.0, 1.0 - d);
glow *= glow;
return vec4(dotColor, glow * level * vGlow) * color;
}
#endif
]]
local function partShader()
local s = shaders.part
if s ~= nil then return s or nil end
if not (love and love.graphics and love.graphics.newShader) then
shaders.part = false
return nil
end
local ok, sh = pcall(love.graphics.newShader, PART_SHADER)
shaders.part = (ok and sh) or false
return shaders.part or nil
end
local PART_FORMAT = {
{ "VertexPosition", "float", 3 },
{ "AtmosData", "float", 4 },
}
local CORNERS = { { -1, -1 }, { 1, -1 }, { 1, 1 }, { -1, 1 } }
local function pushQuad(map, n)
local b = n * 4
map[#map + 1] = b + 1
map[#map + 1] = b + 2
map[#map + 1] = b + 3
map[#map + 1] = b + 1
map[#map + 1] = b + 3
map[#map + 1] = b + 4
end
local function buildPartMesh(points)
if #points == 0 then return nil end
local verts, indices = {}, {}
for i = 1, #points do
local p = points[i]
for c = 1, 4 do
verts[#verts + 1] = { p.x, p.y, p.z,
CORNERS[c][1], CORNERS[c][2], p.phase, p.rate }
end
pushQuad(indices, i - 1)
end
local ok, mesh = pcall(love.graphics.newMesh, PART_FORMAT, verts,
"triangles", "static")
if not ok then return nil end
pcall(mesh.setVertexMap, mesh, indices)
return mesh
end
local function meshesFor(map, L)
local hit = meshCache[map.id]
if hit then return hit end
local M = {
motes = buildPartMesh(L.motes),
flies = buildPartMesh(L.flies),
}
meshCache[map.id] = M
return M
end
local MOTE_COLOR = { 1.0, 0.96, 0.78 }
local FLY_COLOR = { 0.72, 1.0, 0.45 }
-- The billboard frame: the camera's own right and up, from the same
-- fields every pass sets (per VR eye too -- drawScene runs per eye and
-- reads the eye's camera). Degenerate looks answer nil and the
-- particles sit this one out.
local function billboardAxes(Voxel3D)
local e, fo = Voxel3D.eye, Voxel3D.focus
if not (e and fo) then return nil end
local lx, ly, lz = fo[1] - e[1], fo[2] - e[2], fo[3] - e[3]
local ll = sqrt(lx * lx + ly * ly + lz * lz)
if ll < 1e-6 then return nil end
lx, ly, lz = lx / ll, ly / ll, lz / ll
local rx, rz = -lz, lx
local rl = sqrt(rx * rx + rz * rz)
if rl < 1e-4 then return nil end
rx, rz = rx / rl, rz / rl
return { rx, 0, rz }, { -rz * ly, rz * lx - rx * lz, rx * ly }
end
-- ------- the draw
--
-- Inside the scene pass, in VoxelScene's prop slot. The march borrows
-- the frame's depth through Voxel3D.beginWater -- the same detach Water
-- runs -- and hand-tests every step against it, so the pass itself needs
-- no depth attachment; the particles come after, depth-tested additive
-- geometry like the Stadium flames. Anything missing -- no entry, OFF, a
-- refused shader, no readable depth, no shadow map -- subtracts only
-- itself.
function ForestAtmos.draw(map)
local rung = ForestAtmos.setting:get()
if rung == "off" then return end
local f = ForestAtmos.frame(map)
if not f then return end
local Voxel3D = V.require("Voxel3D")
local ShadowMap = V.require("ShadowMap")
if f.rayAlpha > 0.01 then
if not Voxel3D.depthReadable() then
say("depth", "no readable depth this frame -- beams off, fog stays")
return
end
-- no beams without the sun's own pass: uvVP is only the world -> map
-- transform while a shadow map is actually standing
local sunTex = ShadowMap.active() and ShadowMap.texture()
if not sunTex then
say("sun", "no shadow map standing -- beams off, fog stays")
end
local leaf = leafTexture()
if not leaf then
say("leaf", "leaf field would not build -- beams off, fog stays")
end
local sh = rayShaderFor(rung == "low" and 12 or 24)
local w, h = Voxel3D.size()
local quad = (sh and sunTex and leaf and w) and rayQuad(Voxel3D, w, h)
if sh and sunTex and leaf and w and not quad then
say("quad", "no camera frame for the ray fan -- beams off")
end
if quad then
local _, depth = Voxel3D.beginWater(nil)
if depth then
say("on", "volumetric beams running")
local kx, kz = DayNight.shearAt(DayNight.T.day)
local kl = sqrt(kx * kx + kz * kz + 1)
love.graphics.setBlendMode("add", "alphamultiply")
love.graphics.setShader(sh)
pcall(sh.send, sh, "depthTex", depth)
pcall(sh.send, sh, "sunMap", sunTex)
pcall(sh.send, sh, "leafTex", leaf)
pcall(sh.send, sh, "vp", "row", Voxel3D.vp)
pcall(sh.send, sh, "sunVP", "row", ShadowMap.uvVP)
pcall(sh.send, sh, "sunBias", ShadowMap.bias)
pcall(sh.send, sh, "eye", Voxel3D.eye)
pcall(sh.send, sh, "curve",
{ Voxel3D.curveX or 0, Voxel3D.curveZ or 0,
Voxel3D.curveK or 0 })
pcall(sh.send, sh, "screen", { w, h })
pcall(sh.send, sh, "fogW",
{ f.fog.density, f.fog.heightK,
f.cfg.canopyY or 56, f.cfg.fadeTo or 28 })
pcall(sh.send, sh, "shear", { kx, kz, f.rayReach })
pcall(sh.send, sh, "rayColor", f.rayColor)
pcall(sh.send, sh, "strength", f.rayStrength * f.rayAlpha)
pcall(sh.send, sh, "sunward", { -kx / kl, 1 / kl, -kz / kl })
pcall(sh.send, sh, "wind", { 0.016, 0.009 })
pcall(sh.send, sh, "time", ForestAtmos.time)
pcall(love.graphics.draw, quad)
love.graphics.setShader()
love.graphics.setBlendMode("alpha")
end
Voxel3D.endWater()
end
end
if rung == "full" then
local L = layoutFor(map)
local M = L and meshesFor(map, L)
local psh = partShader()
local axisR, axisU = billboardAxes(Voxel3D)
if M and psh and axisR then
Voxel3D.blend("add")
if Voxel3D.beginEffect(psh) then
pcall(psh.send, psh, "vp", "row", Voxel3D.vp)
pcall(psh.send, psh, "curve",
{ Voxel3D.curveX or 0, Voxel3D.curveZ or 0,
Voxel3D.curveK or 0 })
pcall(psh.send, psh, "axisR", axisR)
pcall(psh.send, psh, "axisU", axisU)
pcall(psh.send, psh, "time", ForestAtmos.time)
if M.motes and f.moteLevel > 0.02 then
pcall(psh.send, psh, "size", 1.4)
pcall(psh.send, psh, "sway", { 5, 2.5 })
pcall(psh.send, psh, "blinky", 0)
pcall(psh.send, psh, "dotColor", MOTE_COLOR)
pcall(psh.send, psh, "level", f.moteLevel * 0.5)
pcall(love.graphics.draw, M.motes)
end
if M.flies and f.fireflyLevel > 0.02 then
pcall(psh.send, psh, "size", 1.6)
pcall(psh.send, psh, "sway", { 10, 4 })
pcall(psh.send, psh, "blinky", 1)
pcall(psh.send, psh, "dotColor", FLY_COLOR)
pcall(psh.send, psh, "level", f.fireflyLevel * 0.85)
pcall(love.graphics.draw, M.flies)
end
Voxel3D.endEffect()
end
Voxel3D.blend(nil)
end
end
end
return ForestAtmos
+7 -6
View File
@@ -216,13 +216,14 @@ end
StadiumBuild.stance = stance
-- Whether this species' standby loop is corrupt in the source data.
-- Whether this species' standby loop is corrupt as extracted.
--
-- 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.
-- No species trips this today. Exeggutor, Tangela and Magmar used to, when
-- the flags byte was misread and their hermite-keyframe animations were
-- decoded as packed streams, throwing bones hundreds of units off the body.
-- It stays as the guard against the next extraction bug: played, a broken
-- idle looks like a Pokemon coming apart, and the mod would rather show
-- the sprite fallback (see StadiumMon).
--
-- 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
+47 -19
View File
@@ -22,9 +22,10 @@
-- 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.
-- THE ANIMATIONS are packed per-frame streams of 12- or 16-bit fields for
-- 146 of the 151 species. The other five -- Pidgeot, Dodrio, Exeggutor,
-- Tangela and Magmar -- use the game's hermite-keyframe mode (flags & 8),
-- some for every animation, some for a subset.
--
-- ------- arithmetic, and why there are no bit operations here
--
@@ -86,10 +87,31 @@ local function roundHalfEven(x)
return f + 1
end
-- Python's round(x, nd). Multiplying by 10^nd first is WRONG: the multiply
-- itself rounds, and a value just under a decimal boundary can land exactly
-- on it -- Magmar's attack scale track holds 1.008874999..., whose *1e5 is
-- exactly 100887.5, and the fold then differed from the oracle by one bit.
-- Python rounds the EXACT binary expansion, and so does LuaJIT's own float
-- formatter, so the digits come from string.format instead.
local function roundTo(x, nd)
if nd == 0 then return roundHalfEven(x) end
local m = 10 ^ nd
return roundHalfEven(x * m) / m
if x ~= x or x == math.huge or x == -math.huge then return x end
-- A true decimal tie -- the exact expansion terminating one digit past nd
-- with a 5 -- only a dyadic value can produce, and its zeros then run out
-- forever; any other double shows a nonzero digit within ~18 places. The
-- formatter rounds such a tie away from zero where Python rounds it to
-- even, so it is the one case the digits cannot settle.
local s = string.format("%." .. (nd + 24) .. "f", x)
local dot = s:find(".", 1, true)
local tail = s:sub(dot + nd + 1)
if not tail:match("^50*$") then
return tonumber(string.format("%." .. nd .. "f", x))
end
local head = s:sub(1, dot + nd) -- sign, integer part, nd digits
local last = head:byte(-1) - 48
if last % 2 == 0 then return tonumber(head) end
-- an odd digit bumps to even with no carry
return tonumber(head:sub(1, -2) .. string.char(head:byte(-1) + 1))
end
StadiumFragment.roundHalfEven = roundHalfEven
@@ -572,7 +594,9 @@ Anim.__index = Anim
local function newAnim(frag, off)
return setmetatable({
f = frag, off = off,
flags = frag:u8(off),
-- the u16 at +0: the flag bits live in its LOW byte, so a u8 read at +0
-- gets the always-zero high byte and silently hides hermite mode
flags = frag:u16(off),
startFrame = frag:u16(off + 4),
loopStart = frag:u16(off + 6),
nChannels = frag:u16(off + 8),
@@ -592,20 +616,20 @@ function Anim:chan(i)
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.
-- Packed per-frame streams (flags & 8 == 0).
--
-- 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.
-- A count of 0 means the component has no stream. In the ROM that only ever
-- happens in HERMITE animations, where a count under 2 means "the offset
-- field IS the constant value" -- no packed animation of any of the 151
-- species carries an empty channel, so the bind-pose fallback (the nil
-- return) is dead code kept as a safety net.
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
-- func_80016848 reads the u16 offset field back as a SIGNED constant
if wide then return signed(c.oTrans, 16) + 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)
@@ -626,9 +650,9 @@ function Anim:scalePacked(c, frame)
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.
-- Hermite keyframes (flags & 8): Pidgeot, Dodrio, Exeggutor, Tangela and
-- Magmar. src/17300.c func_80016934 (narrow, 6-byte keys) and func_80016B30
-- (wide, 8-byte keys with a separate out-tangent).
function Anim:hermite(base, n, frame, wide)
local f = self.f
local stride = wide and 8 or 6
@@ -673,7 +697,10 @@ function Anim:rotKey(c, frame)
floor(c.interp / 2) % 2 == 1) / 10.0
end
deg = deg % 360.0
return floor(deg / 360.0 * 65536.0)
-- func_80016DE0 returns s16: the f32 -> s16 cast WRAPS an angle above 180
-- degrees to its negative twin, and the pack writer clamps i16, so an
-- unwrapped value would pin at 32767 instead
return signed(floor(deg / 360.0 * 65536.0) % 65536, 16)
end
function Anim:scaleKey(c, frame)
@@ -721,7 +748,8 @@ Aux.__index = Aux
local function newAux(frag, off)
return setmetatable({
f = frag,
flags = frag:u8(off),
flags = frag:u16(off), -- low byte, same layout as Anim
startFrame = frag:u16(off + 4),
loopStart = frag:u16(off + 6),
nChannels = frag:u16(off + 8),
+15 -5
View File
@@ -56,6 +56,13 @@ StadiumInstall.MARKER = StadiumInstall.DIR .. "/pack.info"
-- than misread. Must track StadiumPack's magic.
StadiumInstall.FORMAT = "DSM3"
-- Bumped when the packs' CONTENT changes without the byte layout moving, so
-- a cache built by an older extractor is rebuilt rather than trusted. Rev 2
-- is the hermite-animation decode fix: the five keyframe species (Pidgeot,
-- Dodrio, Exeggutor, Tangela, Magmar) come out garbled or bind-posed from
-- any rev-1 build.
StadiumInstall.REV = 2
StadiumInstall.COUNT = 151
-- Named ROM files, then any ROM at all sitting in the folder.
@@ -138,9 +145,10 @@ local function readMarker()
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*)")
local format, count, md5, rev = text:match("^(%S+)%s+(%d+)%s*(%S*)%s*(%S*)")
if not format then return nil end
return { format = format, count = tonumber(count), md5 = md5 }
return { format = format, count = tonumber(count), md5 = md5,
rev = tonumber(rev) }
end
-- Whether a complete, current set of packs is on disk.
@@ -150,7 +158,8 @@ 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
and m.count == StadiumInstall.COUNT
and m.rev == StadiumInstall.REV) and true or false
return readyCache
end
@@ -315,8 +324,9 @@ function StadiumInstall.step()
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 "")))
("%s %d %s %d\n"):format(StadiumInstall.FORMAT, job.total,
tostring(job.md5 or ""),
StadiumInstall.REV))
readyCache = nil
StadiumPack.forget()
end
+15 -24
View File
@@ -205,32 +205,23 @@ end
-- 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
-- ------- staticPose: the corrupt-idle escape hatch
--
-- 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`.
-- StadiumBuild.idleIsBroken measures whether a species' standby loop throws
-- bones off the body, and the pack carries the verdict as `staticPose`. A
-- species so marked DECLINES here -- the Game Boy's own battle sprite
-- stands on the tile instead, drawn by the same 2D-3D path every species
-- uses when its model is unavailable -- because a bind pose held for a
-- whole fight reads as broken, not as "this one does not animate".
--
-- 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.
-- No species is marked today. Exeggutor, Tangela and Magmar used to be:
-- their animations are hermite keyframes (flags & 8), the extractor misread
-- the flags byte and decoded them as packed streams, and the exploding
-- result tripped the detector (Pidgeot and Dodrio were garbled by the same
-- bug, just not hard enough to trip it). The detector stays, keyed on the
-- DATA rather than a list of dex numbers, so a future extraction bug that
-- corrupts a species' idle falls back to the sprite instead of coming
-- apart on the field -- and nothing here has to be edited when it does.
function StadiumMon:setSpecies(dex)
if dex == self.species then return self.rig ~= nil end
if self.rig then self.rig:release() end
+16 -6
View File
@@ -55,14 +55,22 @@ local floor = math.floor
--
-- 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.
-- tested against gets built. It is second because a locally built CURRENT
-- cache should win over whatever a checkout happens to have lying around --
-- current as judged by StadiumInstall's marker, so a cache an old extractor
-- built does not shadow a fresh set (see readPack).
StadiumPack.CACHE_DIR = "dramatic_shape/stadium"
StadiumPack.DIR = "assets/stadium"
local function readPack(species)
-- The cache only counts when StadiumInstall's marker says it is a
-- complete, CURRENT build -- an old cache (a rev the extractor has since
-- fixed, a format that moved) must not shadow a fresh shipped set, and a
-- half-written folder must not be read at all. Required lazily: Install
-- requires this module at load, so the reverse edge cannot be taken then.
local rel = ("%s/%03d.dsm"):format(StadiumPack.CACHE_DIR, species)
if love and love.filesystem and love.filesystem.getInfo then
if love and love.filesystem and love.filesystem.getInfo
and V.require("StadiumInstall").ready() then
local okInfo, info = pcall(love.filesystem.getInfo, rel, "file")
if okInfo and info then
local ok, bytes = pcall(love.filesystem.read, rel)
@@ -83,9 +91,11 @@ end
-- 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.
-- attack and not a damage reaction (see StadiumMon's STATES). The slot TABLE
-- is indexed by position, but the name also reaches the packed files: the
-- packers bake it into the animation NAME strings, so it has to match
-- tools/stadium_pack.py's CONTEXTS *and* pipeline/battle.py's CONTEXT_SLOTS,
-- or the oracle diff reports every species.
StadiumPack.CONTEXT = {
"idle", "attack_default", "faint", "entrance", "reaction_169", "reaction_170",
"reaction_171", "reaction_172", "reaction_173", "reaction_174",
+54
View File
@@ -72,6 +72,7 @@ Voxel3D.FACE_SHADE = {
local SHADER = [[
varying float vShade;
varying vec3 vSun; // this fragment's place in the sun's view
varying float vFog; // how deep into the map's haze it stands
#ifdef VOXEL_GRID
// model space, one unit per voxel -- see VoxelGrid. Precision matters
// here in a way it does not for a colour: the seam is the FRACTIONAL
@@ -87,6 +88,7 @@ local SHADER = [[
uniform vec3 eye;
uniform float pull;
uniform vec3 curve; // xy = the focus in world XZ, z = k; 0 = off
uniform vec4 fogInfo; // density, start, heightK; density 0 = clear
attribute float VertexShade;
vec4 position(mat4 transform_projection, vec4 vertex_position) {
vShade = VertexShade;
@@ -108,6 +110,19 @@ local SHADER = [[
// answered. (The pull below is excluded for the same reason: it is a
// depth trick aimed at the camera's own buffer.)
vSun = (sunVP * (sunModel * vertex_position)).xyz;
// THE MAP'S HAZE (see ForestAtmos): how much fog stands between the
// eye and this vertex -- distance dissolves into it, altitude climbs
// out of it. Worked out on the FLAT world like the shadow lookup
// above (the curve is a trick played on the viewer, not weather),
// and per VERTEX: on meshes built a face per voxel the interpolated
// answer is indistinguishable from per-fragment fog at a fraction of
// the cost.
vFog = 0.0;
if (fogInfo.x > 0.0) {
float fogRun = max(0.0, length(w.xyz - eye) - fogInfo.y);
vFog = (1.0 - exp(-fogInfo.x * fogRun))
* exp(-max(w.y, 0.0) * fogInfo.z);
}
// The curved world (see WorldCurve): drop every vertex by the square
// of how far its column stands from the camera's focus. Applied AFTER
// the shadow lookup above and clear of the wireframe's model space, so
@@ -205,6 +220,7 @@ local SHADER = [[
uniform vec3 ghostColor; // the flat silhouette colour
uniform float ghost; // 0 = shade normally, 1 = flatten to it
uniform vec3 dayTint; // the hour's light on the world; 1,1,1 = noon
uniform vec3 fogColor; // what the haze is made of (see Voxel3D.fog)
uniform Image glassMask; // opaque where the atlas texel is window glass
uniform vec2 glassSize; // the mask's dimensions: tc -> atlas texels
uniform float glassNight; // 0 = daylight .. 1 = the lamps are on
@@ -256,6 +272,11 @@ local SHADER = [[
vec3 lamp = vec3(1.0, 0.84, 0.5) * (0.5 + 0.55 * shine);
rgb = mix(pane, lamp, glassNight * glass);
}
// the haze stands between the eye and the SURFACE, so it lands after
// every surface term -- sun, seams, glass -- and before only the
// ghost, which must stay one solid readable shape whatever the
// weather (see below)
rgb = mix(rgb, fogColor, vFog);
// The hidden player is a SHAPE, not a dimmed picture of itself. Tinting
// through `color` could only multiply the sprite's own pixels, which
// darkens each one by its own amount and keeps the character's internal
@@ -687,6 +708,14 @@ end
-- answers it, so a caller that never does draws exactly what it always drew.
Voxel3D.tint = { 1, 1, 1 }
-- The map's haze, set the same way (VoxelScene and BattleScene ask
-- ForestAtmos, who knows which maps have weather): a table of
-- { color = {r,g,b}, density, start, heightK }, or nil for a clear day.
-- nil -- the default -- sends density 0, so a caller that never heard of
-- fog draws exactly what it always drew, and no pass can inherit the
-- last one's weather.
Voxel3D.fog = nil
-- The window-glass pass, set the same way and for the same reason: the
-- MASK belongs to the map's tileset (GlassMask.texture) and how lit the
-- panes are belongs to the hour and to being outdoors at all
@@ -922,6 +951,12 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
pcall(sh.send, sh, "ghostColor", Voxel3D.GHOST_COLOR)
-- the hour's light, as the caller last set it (see Voxel3D.tint)
pcall(sh.send, sh, "dayTint", Voxel3D.tint or { 1, 1, 1 })
-- and the map's haze (see Voxel3D.fog), density 0 when there is none
local fog = Voxel3D.fog
pcall(sh.send, sh, "fogColor", (fog and fog.color) or { 0, 0, 0 })
pcall(sh.send, sh, "fogInfo", fog and
{ fog.density or 0, fog.start or 0, fog.heightK or 0, 0 }
or { 0, 0, 0, 0 })
-- the window glass: the tileset's mask (or the blank -- the sampler is
-- declared either way, and unbound is a driver-dependent crash), how lit
-- the panes are, and the movement-fed glint as the caller last set it
@@ -1127,6 +1162,25 @@ function Voxel3D.endWater()
if activeShader then love.graphics.setShader(activeShader) end
end
-- A custom shader for the length of a draw, inside the pass. What makes
-- this a pair rather than a bare setShader at the call site is the way
-- BACK: the scene shader this pass bound is module-local (activeShader,
-- above), so only this file can restore it -- the same restore endWater
-- performs, without the canvas shuffle. Answers false when there is no
-- pass to come back to, and the caller skips its draw entirely.
function Voxel3D.beginEffect(shader)
if not (active and shader) then return false end
love.graphics.setShader(shader)
love.graphics.setColor(1, 1, 1, 1)
return true
end
function Voxel3D.endEffect()
if not active then return end
love.graphics.setColor(1, 1, 1, 1)
if activeShader then love.graphics.setShader(activeShader) end
end
-- Whether a reflective water pass can run in this frame at all -- there is
-- a depth texture to read. Callers use it to choose between the water
-- shader and an ordinary terrain draw before they start moving canvases.
+14
View File
@@ -914,6 +914,12 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
Voxel3D.glassNight = outdoor and DayNight.windowLight() or 0
local g = VoxelScene.glintStep(glint, cx, cy)
Voxel3D.glassPhase, Voxel3D.glassGlint = g.phase, g.amp
-- and the map's atmosphere, if it has one (see ForestAtmos): the haze
-- the scene shader folds every surface into, in the hour's colour.
-- nil for every map without an entry -- a clear day, exactly as before.
local ForestAtmos = V.require("ForestAtmos")
local atmos = ForestAtmos.frame(state.map)
Voxel3D.fog = atmos and atmos.fog or nil
local function atlasFor(map)
return TerrainAtlas.forMap(map, modeColors(paletteFor, map))
@@ -1152,6 +1158,14 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
end
-- The map's atmosphere -- god rays down from the invisible canopy, and
-- whatever drifts through them (see ForestAtmos). Additive over the
-- finished depth buffer, so the trees occlude the light and the light
-- writes nothing; here in the prop slot, after everything the beams
-- should fall across and inside drawScene so VR gets them per eye. On
-- the one map that has any, today.
ForestAtmos.draw(state.map)
-- The VR pokedex in the player's left hand, last of all: a prop over
-- the world drawn with real depth, so leaning it into a wall still
-- occludes honestly. Its frame only exists while a session is live and
+24 -2
View File
@@ -940,7 +940,16 @@ vec2 waveUV(vec2 tc, vec2 col) {
// can afford it -- the colour is a colour, and tc/sc arrived through
// LOVE's mediump plumbing whatever this signature says -- and the maths
// below runs on the stage default the moment the values touch a local.
vec4 effect(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc) {
//
// Which precision that has to BE is not ours to know: LOVE 12 forward-
// declares effect() under a different one, and pins that matched 11's
// prototype are the mismatch there -- the same refusal, from the other
// side, with the water falling back to flat. So the qualifier is a define
// the Lua side fills in, and Water.shader compiles the pinned form first
// and the bare one only if that is refused. Whichever prototype a runtime
// brought, one of the two agrees with it.
vec4 effect(EFFECT_PREC vec4 color, Image tex, EFFECT_PREC vec2 tc,
EFFECT_PREC vec2 sc) {
// THE DEPTH TEST, done here because the buffer that would have done it is
// detached for the length of this pass so it can be READ (see the header).
// Same comparison, same buffer, same result: a building in front of a pond
@@ -1133,7 +1142,7 @@ end
Water._trainSource = trainSource -- named for the suite
local function source(grid)
local function source(grid, bare)
local src = SHADER_SRC:gsub("//@CRATERS", (craterSource():gsub("%%", "%%%%")))
src = src:gsub("//@TRAINS", (trainSource():gsub("%%", "%%%%")))
local head = ("#define RAY_STEPS %d\n#define RAY_REFINE %d\n"
@@ -1141,6 +1150,12 @@ local function source(grid)
:format(Water.RAY_STEPS, Water.RAY_REFINE, Water.WAVE_STEPS,
Water.WAVE_STRIDE)
if grid then head = head .. "#define VOXEL_GRID 1\n" end
-- effect()'s parameter precision -- see the signature for why it cannot
-- simply be spelled there. Empty is a define all the same: the params
-- then carry the stage default, which is what a prototype declared
-- without qualifiers wants.
head = head .. (bare and "#define EFFECT_PREC\n"
or "#define EFFECT_PREC mediump\n")
return head .. src
end
@@ -1159,6 +1174,13 @@ function Water.shader(grid)
shaders[grid] = false
else
local ok, sh = pcall(love.graphics.newShader, source(grid))
if not ok then
-- the pinned prototype was the wrong one for this runtime; the bare
-- one is the only other shape there is, and a driver that refuses
-- both was never going to draw this water anyway
local bareOk, bareSh = pcall(love.graphics.newShader, source(grid, true))
if bareOk then ok, sh = bareOk, bareSh end
end
if not ok and V and V.mod and V.mod.log then
-- once, where it can be read: the fallback is flat water, which is
-- easy to look at and impossible to diagnose without this line
+20
View File
@@ -90,6 +90,7 @@ local BattleExit = V.require("BattleExit")
local DayNight = V.require("DayNight")
local DayTint = V.require("DayTint")
local Water = V.require("Water")
local ForestAtmos = V.require("ForestAtmos")
local AntiAlias = V.require("AntiAlias")
local FirstPerson = V.require("FirstPerson")
local FreeMove = V.require("FreeMove")
@@ -191,6 +192,9 @@ mod.content.render_pipelines:register("voxel", {
-- battles and menus, and a CYCLE evening falls mid-fight exactly as it
-- would mid-walk
DayNight.update(dt)
-- the atmosphere's own clock (shaft shimmer, drifting motes), on the
-- same tick so the beams keep breathing through a dialog box
ForestAtmos.update(dt)
-- The overworld battle rides this hook rather than owning a pipeline of
-- its own, because it owns no pass of the FRAME: it draws under a battle
-- screen the engine composites, which is not a stage the registry has.
@@ -302,6 +306,7 @@ mod.content.render_pipelines:register("voxel", {
OverworldBattle.invalidate()
AntiAlias.invalidate()
ChunkMesher.invalidate() -- no map id = every cached mesh
ForestAtmos.invalidate() -- shaft/particle meshes and shader sentinels
VR.invalidate() -- the mirror, and FBO ids of dead canvases
end,
})
@@ -427,6 +432,18 @@ local SETTINGS = {
.. "shoreline, the trees and the buildings behind it; SKY is the sky, "
.. "the sun and the moon alone, which is most of the look for a "
.. "fraction of the cost." },
-- `full` for the AA reason: additive shafts are fill rate, and under 4X
-- supersampling that is a question about the hardware, not the look.
{ ForestAtmos.setting,
"The air of the deep woods (Viridian Forest): a ground haze, and "
.. "volumetric light let down through the unseen canopy overhead -- "
.. "gold spears of sun by day, silver moon rays at night, pollen "
.. "drifting through the beams and fireflies once they cool. LOW "
.. "keeps the haze, halves the beam march and stands the particles "
.. "down. On a phone the row offers LOW alone: the beams need a "
.. "depth texture the pass can read back, and no mobile driver here "
.. "grants one.",
full = true },
-- `full` marks a row FULL does not take away. FULL owns the diorama's own
-- knobs; what a battle is drawn over, and how it is framed, are not that.
-- Off the OPTIONS menu while VR is on: the headset REQUIRES staged
@@ -914,6 +931,9 @@ mod.events:on("map.reloaded", function(payload)
if payload and payload.reason == "colors" then return end
local mapId = payload and (payload.mapId or (payload.map and payload.map.id))
if mapId then ChunkMesher.invalidate(mapId) end
-- the atmosphere's layout stands on the same carved stamps the meshes
-- do, so it goes stale on exactly the same event
if mapId then ForestAtmos.invalidate(mapId) end
end)
-- ------- rows come and go, so the menu has to notice
+2 -2
View File
@@ -1,7 +1,7 @@
{
"id": "DRAMATIC_SHAPE",
"name": "Dramatic Shape Voxel Mod",
"version": "1.6.0",
"version": "1.6.2",
"api": 2,
"entry": "main.lua",
"profile": "content",
@@ -10,7 +10,7 @@
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"conflicts": ["ds_fp_ceiling"],
"permissions": [
"engine_internals"
],
+5 -2
View File
@@ -28,9 +28,12 @@ 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',
166: ('attack_default', 'data',
'Resolves to animation 2 for 149/151 species, the same animation slots '
'178-181 use in the reaction paths.'),
'178-181 use in the reaction paths. Called "hit" until the move table '
'was read against it: it is the animation most of a species\' MOVES '
'play, the default attack rather than a damage reaction (the name has '
'to match lib/StadiumPack.lua\'s CONTEXT and StadiumBuild\'s CONTEXTS).'),
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 '
+2 -2
View File
@@ -107,7 +107,7 @@ def bind_extent(data):
# 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']
NAME_PREF = ['idle', 'attack_default', 'faint', 'entrance', 'struggle', 'flinch']
def label_animations(data, rows, moves):
@@ -294,7 +294,7 @@ def main(argv):
moves_out = []
if move_rows:
default_anim = {p['species']: next(
(a['index'] for a in p['animations'] if 'hit' in a.get('contexts', [])), -1)
(a['index'] for a in p['animations'] if 'attack_default' 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
+20 -8
View File
@@ -315,7 +315,11 @@ class Animation:
def __init__(self, frag, off):
f = self.f = frag
self.off = off
self.flags = f.u8(off)
# The flags live in the LOW byte of the u16 at +0 -- reading the byte
# AT +0 gets the always-zero high byte, which silently turns every
# hermite animation (flags & 8: Pidgeot, Dodrio, Exeggutor, Tangela,
# Magmar) into a packed-stream read of keyframe tables.
self.flags = f.u16(off)
self.startFrame= f.u16(off + 4)
self.loopStart = f.u16(off + 6)
self.nChannels = f.u16(off + 8)
@@ -333,16 +337,20 @@ class Animation:
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".
# A count of 0 means the component has no stream. In the ROM that only
# ever happens in HERMITE animations, where a count under 2 means "the
# offset field IS the constant value" -- no packed animation of any of the
# 151 species carries an empty channel, so the bind-pose fallback here is
# dead code kept as a safety net.
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)
# (s16) casts both ways: func_80016848 reads the u16 offset field
# back as a signed constant.
return float(signed(c['oTrans'], 16) 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))
@@ -404,7 +412,11 @@ class Animation:
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)
# func_80016DE0 returns s16: the f32 -> s16 cast WRAPS an angle above
# 180 degrees to its negative twin. Same binary angle either way, but
# the packer stores i16 with clamping, so an unwrapped 350-degree
# value would pin at 32767 (= 180 degrees) instead.
return signed(int(deg / 360.0 * 65536.0) & 0xFFFF, 16)
def _scale_key(self, c, frame):
if c['nScale'] < 2:
@@ -439,7 +451,7 @@ class AuxAnimation:
def __init__(self, frag, off):
f = self.f = frag
self.flags = f.u8(off)
self.flags = f.u16(off) # low byte, same layout as Animation
self.startFrame= f.u16(off + 4)
self.loopStart = f.u16(off + 6)
self.nChannels = f.u16(off + 8)
+197
View File
@@ -0,0 +1,197 @@
-- data/battle_arenas.lua, edited in place by line.
--
-- The arena editor (tests/arena_editor.lua) writes this file back every time
-- a map is committed, and most of what is in it is PROSE: which alternatives
-- were rejected for each area and what was wrong with them. That argument is
-- the part a tool cannot reconstruct, so nothing here regenerates the file --
-- an entry that changed has its own lines replaced, a new one is appended,
-- and every other byte comes through exactly as it was.
--
-- Kept out of the driver so it can be tested without a game: a bug in here
-- writes a config file, and the failure mode of a bad one is silent -- every
-- map loses its authored spot and battles quietly start happening somewhere
-- else. `apply` therefore never decides on its own that its output is good;
-- it hands back the text and the caller parses it back and checks it says
-- what was asked for before anything is overwritten.
--
-- local Config = dofile("mods/DramaticShapeVoxelMod/tests/arena_config.lua")
-- local text, report = Config.apply(oldText, { ROUTE_1 = { x = 4, y = 14,
-- shape = "narrow" } })
--
-- An edit's value is an entry table, `false` for an authored refusal, or
-- Config.REMOVE to take the entry out altogether.
local Config = {}
Config.REMOVE = setmetatable({}, { __tostring = function() return "REMOVE" end })
-- The heading new maps are collected under, so a file worked over several
-- sessions grows one list rather than one heading per session.
Config.SECTION = " -- ------- added by tests/arena_editor"
-- Lossless both ways: a trailing newline becomes a trailing empty line, and
-- concat puts it back.
function Config.splitLines(text)
local out, pos = {}, 1
while true do
local a, b = text:find("\n", pos, true)
if not a then
out[#out + 1] = text:sub(pos)
break
end
out[#out + 1] = text:sub(pos, a - 1)
pos = b + 1
end
return out
end
-- One entry, laid out the way the file already lays them out: on one line
-- where it fits inside the file's own margin, and wrapped under the brace
-- where it does not (which is how the cross-floor entries are written).
function Config.entryLines(id, entry)
if entry == false then
return { (" [%q] = false,"):format(id) }
end
local head = (" [%q] = { "):format(id)
local parts = {}
if entry.map then parts[#parts + 1] = ("map = %q"):format(entry.map) end
parts[#parts + 1] = ("x = %d"):format(entry.x)
parts[#parts + 1] = ("y = %d"):format(entry.y)
parts[#parts + 1] = ("shape = %q"):format(entry.shape or "wide")
if (entry.turn or 0) ~= 0 then
parts[#parts + 1] = ("turn = %d"):format(entry.turn)
end
if entry.cam then parts[#parts + 1] = ("cam = %q"):format(entry.cam) end
local one = head .. table.concat(parts, ", ") .. " },"
if #one <= 79 then return { one } end
local first = entry.map and 3 or 2
return {
head .. table.concat(parts, ", ", 1, first) .. ",",
(" "):rep(#head) .. table.concat(parts, ", ", first + 1) .. " },",
}
end
-- Where an id's entry starts and ends, plus how many comment lines sit
-- directly above it. The end is the first line that closes the table, so a
-- wrapped entry is found whole rather than half-replaced.
function Config.findEntry(lines, id)
local pat = '^%s*%["' .. id:gsub("(%W)", "%%%1") .. '"%]%s*='
for i = 1, #lines do
if lines[i]:find(pat) then
local note = 0
while i - note - 1 >= 1 and lines[i - note - 1]:find("^%s*%-%-") do
note = note + 1
end
if lines[i]:find("=%s*false%s*,?%s*$") then return i, i, note end
for j = i, #lines do
if lines[j]:find("}%s*,?%s*$") then return i, j, note end
end
return i, i, note
end
end
return nil
end
-- The `}` that closes the returned table: new entries go in above it.
function Config.closingLine(lines)
for i = #lines, 1, -1 do
if lines[i]:find("^}") then return i end
end
return #lines + 1
end
-- Apply `edits` (map id -> entry | false | REMOVE) to `text`.
--
-- Returns the new text and a report: `changed`, `added`, `removed` and
-- `stale` as lists of map ids. `stale` is the one worth acting on -- those
-- entries had comment lines directly above them, and a comment above an
-- entry that has just moved describes where it USED to be.
function Config.apply(text, edits)
local lines = Config.splitLines(text)
local report = { changed = {}, added = {}, removed = {}, stale = {} }
local ordered = {}
for id in pairs(edits) do ordered[#ordered + 1] = id end
table.sort(ordered)
local appended = {}
for _, id in ipairs(ordered) do
local entry = edits[id]
-- found again per edit rather than indexed once up front: each
-- replacement changes the line count under every index after it
local from, to, note = Config.findEntry(lines, id)
if entry == Config.REMOVE then
if from then
for _ = from, to do table.remove(lines, from) end
report.removed[#report.removed + 1] = id
if note > 0 then report.stale[#report.stale + 1] = id end
end
elseif from then
local new = Config.entryLines(id, entry)
for _ = from, to do table.remove(lines, from) end
for k = #new, 1, -1 do table.insert(lines, from, new[k]) end
report.changed[#report.changed + 1] = id
if note > 0 then report.stale[#report.stale + 1] = id end
else
appended[#appended + 1] = id
report.added[#report.added + 1] = id
end
end
if #appended > 0 then
local at, block = nil, {}
for i = 1, #lines do
if lines[i]:find("^%s*%-%-%s+%-+ added by tests/arena_editor") then
at = i + 1
end
end
if not at then
at = Config.closingLine(lines)
block[#block + 1] = ""
block[#block + 1] = Config.SECTION
end
for _, id in ipairs(appended) do
for _, l in ipairs(Config.entryLines(id, edits[id])) do
block[#block + 1] = l
end
end
for k = #block, 1, -1 do table.insert(lines, at, block[k]) end
end
return table.concat(lines, "\n"), report
end
-- Whether two entries say the same thing. `false` and nil are values here --
-- a refusal is not the absence of one -- so this is an equality test and not
-- a truthiness one.
function Config.same(a, b)
if a == b then return true end
if type(a) ~= "table" or type(b) ~= "table" then return false end
return a.x == b.x and a.y == b.y
and (a.shape or "wide") == (b.shape or "wide")
and (a.turn or 0) == (b.turn or 0)
and a.cam == b.cam and a.map == b.map
end
-- Parse a config back and check it says what was committed. This is what
-- stands between a bug in the surgery above and a data file that no longer
-- loads, so it is run on the TEXT before that text replaces anything.
-- Returns the parsed table, or nil and why not.
function Config.verify(text, edits, chunkName)
local chunk, err = load(text, "@" .. (chunkName or "battle_arenas"))
if not chunk then return nil, "would not compile: " .. tostring(err) end
local ok, list = pcall(chunk)
if not (ok and type(list) == "table") then
return nil, "did not return a table: " .. tostring(list)
end
for id, want in pairs(edits) do
local got = list[id]
if want == Config.REMOVE then
if got ~= nil then return nil, id .. " is still in the file" end
elseif not Config.same(want, got) then
return nil, id .. " did not read back as it was committed"
end
end
return list
end
return Config
File diff suppressed because it is too large Load Diff
+209 -11
View File
@@ -319,7 +319,7 @@ local order = {}
for i, row in ipairs(grouped) do order[row.id] = i end
T.check(order["pipeline:tiltshift"] < order["DRAMATIC_SHAPE:grid"],
"the mode's settings follow its pipeline rows")
T.eq(order["DRAMATIC_SHAPE:battles"] - order["pipeline:tiltshift"], 4,
T.eq(order["DRAMATIC_SHAPE:battles"] - order["pipeline:tiltshift"], 5,
"and sit in one unbroken block, not scattered to the end of the list")
T.check(order["void_fill"] > order["DRAMATIC_SHAPE:battles"],
"with the engine's own later rows still after them")
@@ -404,18 +404,23 @@ local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end,
-- (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 "
T.eq(#hookedRows, 11, "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
-- this chunk is one main function and has 200 local slots to spend
local battles, backRow, daytime = hookedRows[6], hookedRows[7], hookedRows[8]
-- the FOREST FX row is hookedRows[5] and AA hookedRows[9]; both are read
-- where they are used rather than named here, because this chunk is one
-- main function and has 200 local slots to spend
T.eq(water.label, "WATER", "the water row carries its label")
T.eq(water.value(), "FULL",
"and defaults to FULL -- reflections are the point of having the row")
water.step({ save = { options = {} }, mods = { modOptions = {} } }, 1)
T.eq(water.value(), "SKY",
"stepping down drops the screen-space march and keeps the sky, sun and moon")
T.eq(hookedRows[5].label, "FOREST FX", "the atmosphere row carries its label")
T.eq(hookedRows[5].value(), "FULL",
"and defaults to FULL -- it only spends anything on a map with an "
.. "atmosphere entry, which is one forest today")
T.eq(daytime.label, "DAYTIME", "the day/night row carries its label")
T.eq(daytime.value(), "SYNC",
"and defaults to SYNC -- no value set follows the clock on the wall")
@@ -499,7 +504,7 @@ do
local AntiAlias = run.loader.exports.DRAMATIC_SHAPE.lib.require("AntiAlias")
local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid")
local aaGame = { save = { options = {} }, mods = { modOptions = {} } }
local aa = hookedRows[8]
local aa = hookedRows[9]
T.eq(aa.label, "AA", "the anti-aliasing row carries its label")
T.eq(aa.value(), "OFF",
"and starts off -- supersampling is a cost knob, and a mod must not spend "
@@ -2909,6 +2914,109 @@ T.check(math.abs(cex - 124) < 1 and math.abs(cey - 56) < 1,
T.check(span(closeRig, shot.player) < span(rig, shot.player),
"the mons render smaller on it, which is what it trades for fitting")
-- ------- the quarter turns
--
-- An arena may be laid down any of the four ways (BattleArena's `turn`), and
-- the whole claim of the feature is that this is a fact about the GROUND and
-- never about the shot: the footprint, the two mons and the camera turn
-- together, so the pair land on the same two anchors at the same size and
-- only what is behind them changes.
--
-- Asserted by REPROJECTING each turn through the real rig, exactly the way
-- the unturned shot is checked above -- so a rig retune, or a sign error in
-- the rotation, says so here rather than in a screenshot nobody takes.
--
-- In its own scope: the suite's main chunk sits at LuaJIT's 200-active-local
-- ceiling, and every local below would be one more of them. The leading
-- semicolon is the file's own convention -- without it the previous
-- statement's `)` and this `(` parse as one call.
;(function()
local corner = { shot.x, shot.y }
-- How wide a mon's own square comes out, measured ACROSS the arena's axis.
-- `span` above offsets along world X, which is across the axis only while
-- the arena is standing the way the mode was drawn; on the odd quarters the
-- axis IS X, so offsetting along it would measure the square's depth --
-- foreshortened by a camera that is looking almost straight down it -- and
-- report a shrinking mon that is nothing of the kind.
local function acrossSpan(cam, point, turn)
local d = (turn % 180 == 0) and { 8, 0 } or { 0, 8 }
local a = project(cam, { point[1] - d[1], point[2] - d[2] })
local b = project(cam, { point[1] + d[1], point[2] + d[2] })
return math.abs(b - a)
end
for _, turn in ipairs({ 0, 90, 180, 270 }) do
BattleCam.reset()
local a = BattleArena.at(corner[1], corner[2], "wide", turn)
T.check(a ~= nil, ("the wide arena places at turn %d"):format(turn))
T.eq(a.turn, turn, ("and carries the turn it was placed at (%d)"):format(turn))
-- the footprint swaps its reach on the odd quarters, which is the whole
-- reason a corridor takes one way round and refuses the other
local wantW = (turn % 180 == 0) and 3 or 6
local wantH = (turn % 180 == 0) and 6 or 3
T.eq(a.w, wantW, ("turn %d is %d cells across"):format(turn, wantW))
T.eq(a.h, wantH, ("and %d deep"):format(wantH))
-- both mons stay inside the footprint they were turned within, and stay
-- three cells apart -- the gap the move animations are scaled against
T.check(a.enemyCell[1] >= a.x and a.enemyCell[1] < a.x + a.w
and a.enemyCell[2] >= a.y and a.enemyCell[2] < a.y + a.h,
("turn %d keeps the foe inside the footprint"):format(turn))
T.check(a.playerCell[1] >= a.x and a.playerCell[1] < a.x + a.w
and a.playerCell[2] >= a.y and a.playerCell[2] < a.y + a.h,
("turn %d keeps the player inside it too"):format(turn))
local gap = math.abs(a.enemyCell[1] - a.playerCell[1])
+ math.abs(a.enemyCell[2] - a.playerCell[2])
T.eq(gap, 3, ("turn %d still stands them three cells apart"):format(turn))
-- and which way the foe lies from the player, which is what `turn` NAMES
local dx = a.enemyCell[1] - a.playerCell[1]
local dy = a.enemyCell[2] - a.playerCell[2]
local want = ({ [0] = { 0, -3 }, [90] = { 3, 0 },
[180] = { 0, 3 }, [270] = { -3, 0 } })[turn]
T.check(dx == want[1] and dy == want[2],
("turn %d puts the foe %d,%d from the player: got %d,%d")
:format(turn, want[1], want[2], dx, dy))
-- THE INVARIANT: the same composition, whichever way round it stands
local r = BattleCam.rig(a, 0)
local ppx, ppy = project(r, a.player)
local eex, eey = project(r, a.enemy)
T.check(ppx ~= nil and eex ~= nil,
("turn %d keeps both marks in front of the camera"):format(turn))
T.check(math.abs(ppx - 26) < 1 and math.abs(ppy - 96) < 1,
("turn %d lands the player's mark on its anchor: (%.2f, %.2f)")
:format(turn, ppx, ppy))
T.check(math.abs(eex - 124) < 1 and math.abs(eey - 56) < 1,
("turn %d lands the enemy's on its own: (%.2f, %.2f)")
:format(turn, eex, eey))
T.check(math.abs(acrossSpan(r, a.player, turn) - 64) < 4,
("turn %d still makes the player's square a back pic wide (64px): got "
.. "%.2f"):format(turn, acrossSpan(r, a.player, turn)))
T.check(math.abs(acrossSpan(r, a.enemy, turn) - 56) < 4,
("turn %d still makes the foe's square a front pic wide (56px): got "
.. "%.2f"):format(turn, acrossSpan(r, a.enemy, turn)))
-- the eye really did move: a turn that left the camera where it was would
-- pass every anchor test above by looking at the pair from the side
if turn ~= 0 then
local base = BattleArena.at(corner[1], corner[2], "wide", 0)
BattleCam.reset()
local r0 = BattleCam.rig(base, 0)
local moved = math.abs(r.eye[1] - r0.eye[1])
+ math.abs(r.eye[3] - r0.eye[3])
T.check(moved > 16,
("turn %d actually moves the camera (%.1f world px)"):format(turn, moved))
end
end
-- degrees in, degrees out, and anything else folded onto the four
T.eq(BattleArena.quarters(360), 0, "a full turn is no turn")
T.eq(BattleArena.quarters(-90), 3, "and a negative one comes round the back")
T.eq(BattleArena.at(1, 1, "wide").turn, 0,
"an arena placed without a turn is the way the mode was drawn")
end)()
-- an arena picks its rig by name, and anything unnamed gets the default
T.eq(BattleCam.rigFor({ cam = "wide" }), BattleCam.RIGS.wide,
"an arena that asks for the wide lens gets it")
@@ -3961,6 +4069,92 @@ T.check(not DayNight.isCanopy({ id = "PALLET_TOWN" }),
T.check(not DayNight.isCanopy(nil), "no map, no canopy")
end
-- ------- the air under the canopy
--
-- ForestAtmos hangs an INVISIBLE canopy above the forest's real trees and
-- lets light down through it: fog for the scene shader, a volumetric
-- march for the beams (GPU-only, not checkable here), colour and strength
-- following the clock. Everything checkable without a GPU is checked:
-- the authored table, the hour's ramp, and the seeded particle deal --
-- which must come out identical on every visit.
do
local ForestAtmos =
run.loader.exports.DRAMATIC_SHAPE.lib.require("ForestAtmos")
local DayNight = run.loader.exports.DRAMATIC_SHAPE.lib.require("DayNight")
-- the authored table
local cfg = ForestAtmos.configFor("VIRIDIAN_FOREST")
T.check(cfg ~= nil, "Viridian Forest has an atmosphere entry")
T.check(cfg and (cfg.canopyY or 0) > 32,
"whose invisible canopy hangs ABOVE the carved tree hulls (y = 32) -- "
.. "a ray is alpha zero at the canopy and only fades in below it")
T.check(ForestAtmos.configFor("PALLET_TOWN") == nil,
"a map without an entry has no atmosphere at all")
T.check(ForestAtmos.configFor(nil) == nil, "and no map id does not crash")
-- the hour's ramp: gold spears by day, silver rays by night, both dying
-- back through the twilight handover
local fmap = { id = "VIRIDIAN_FOREST" }
local day = ForestAtmos.frame(fmap, DayNight.T.day)
local night = ForestAtmos.frame(fmap, DayNight.T.night)
local dusk = ForestAtmos.frame(fmap, DayNight.T.dusk)
T.check(day and night and dusk, "the forest answers at every pin")
T.check(day.rayColor[1] > day.rayColor[3],
"the day's rays are sun-gold: more red than blue")
T.check(night.rayColor[3] > night.rayColor[1],
"the night's are moon-silver: more blue than red")
T.check(dusk.rayAlpha < day.rayAlpha and dusk.rayAlpha < night.rayAlpha,
"and the twilight is the dim handover between the two")
T.check(day.fog.density > 0 and night.fog.density > 0,
"the haze never lifts entirely, day or night")
T.check(day.moteLevel > 0.5 and day.fireflyLevel < 0.05,
"pollen drifts through the day's beams, with no fireflies out")
T.check(night.fireflyLevel > 0.5 and night.moteLevel < 0.05,
"and the night shift trades them")
-- the seeded deal: the same particles on every visit (the beams place
-- themselves -- they are marched from the shadow map, not dealt)
local tcfg = { canopyY = 56, fadeTo = 28, seed = 77,
motes = { count = 12 }, fireflies = { count = 6 } }
local a = ForestAtmos.layout(tcfg, 320, 320)
local b = ForestAtmos.layout(tcfg, 320, 320)
T.eq(#a.motes, 12, "the pollen musters at authored strength")
T.eq(#a.flies, 6, "the fireflies too")
local same = true
for i = 1, #a.motes do
local m1, m2 = a.motes[i], b.motes[i]
same = same and m1.x == m2.x and m1.y == m2.y and m1.z == m2.z
end
T.check(same, "and the deal comes out identical on every visit")
local under = true
for _, m in ipairs(a.motes) do
if m.y >= tcfg.canopyY then under = false end
end
T.check(under, "everything drifts BELOW the canopy it lives under")
-- the tuning override, the same handle arena_editor holds on battles
ForestAtmos.setOverride("PALLET_TOWN", { canopyY = 40, fog = {} })
T.check(ForestAtmos.configFor("PALLET_TOWN") ~= nil,
"an override stages an atmosphere a driver can tune live")
ForestAtmos.setOverride("VIRIDIAN_FOREST", false)
T.check(ForestAtmos.configFor("VIRIDIAN_FOREST") == nil,
"false is meaningful: this map has none, whatever the file says")
ForestAtmos.setOverride("PALLET_TOWN", nil)
ForestAtmos.setOverride("VIRIDIAN_FOREST", nil)
T.check(ForestAtmos.configFor("VIRIDIAN_FOREST") ~= nil
and ForestAtmos.configFor("PALLET_TOWN") == nil,
"and nil hands both back to the authored table")
-- the row: OFF is the ladder's last rung and the frame answers nothing
T.eq(ForestAtmos.setting.values[1], "full",
"the atmosphere defaults to FULL -- it costs a handful of quads and "
.. "exists on one map")
ForestAtmos.setting:sync("off")
T.check(ForestAtmos.frame(fmap, DayNight.T.day) == nil,
"OFF answers no frame at all: no fog uniform, no draw, no spend")
ForestAtmos.setting:sync("full")
end
-- ------- a shadow keeps hold of the feet that throw it
--
-- The depth compare forgives `slack` world pixels so lit ground does not
@@ -5581,11 +5775,15 @@ for _, dex in ipairs({ 25, 6, 95, 143 }) do
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")
-- No species is held at its bind pose any more. Magmar (with Pidgeot,
-- Dodrio, Exeggutor and Tangela) uses the game's hermite-keyframe animation
-- mode, which the extractor used to misread as packed streams -- the flags
-- byte was read at +0, the always-zero high half of the u16 -- and the
-- packer then declared their exploding standby loops corrupt. The broken-
-- idle detector stays as the guard, so this asserts it no longer fires.
T.eq(Pack.load(126).staticPose, false,
"Magmar animates -- its hermite animations decode correctly now")
T.eq(pikachu.staticPose, false, "and a packed-stream species does too")
end)()
Pipelines.reset()
+137
View File
@@ -0,0 +1,137 @@
-- Driver: screenshot the forest's atmosphere.
--
-- Viridian Forest through the pins: gold shafts and drifting pollen by
-- day, silver moon rays and fireflies at night, the haze under both, one
-- first-person frame standing inside a beam, and the control shots -- the
-- row OFF (which must be pixel-identical to a run before the feature
-- existed) and LOW (haze, half the shafts, no particles).
--
-- Deterministic on purpose: encounters killed, tile animation frozen, the
-- atmosphere's own clock pinned, so an AB_TAG=before/after pair diffs
-- clean (see tools/voxel-survey.md for the workflow).
--
-- SHOT_DIR=.scratchpad/forestfog AB_TAG=after \
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/forest_fog_shots.lua lovec.exe .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local ROOT = (os.getenv("SHOT_DIR") or ".scratchpad/forestfog")
.. "/" .. (os.getenv("AB_TAG") or "after")
local exports = game.mods and game.mods.exports
local handle = exports and exports.DRAMATIC_SHAPE
if not (handle and handle.lib) then
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
return
end
local lib = handle.lib
local DayNight = lib.require("DayNight")
local ChunkMesher = lib.require("ChunkMesher")
local Voxel = lib.require("VoxelState")
local ForestAtmos = lib.require("ForestAtmos")
if not (game.data.maps and game.data.maps.VIRIDIAN_FOREST) then
U.log("no VIRIDIAN_FOREST in this dataset")
return
end
-- ------- the determinism recipe (see round_regress_shots)
require("src.world.OverworldController").rollEncounter =
function() return nil end
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 1
Zoom.applyOptions(game.save.options)
end)
-- pin the atmosphere's own clock: every shimmer and every mote is a
-- pure function of this number
ForestAtmos.frozen = true
ForestAtmos.time = 5
local function setTime(value)
DayNight.setting:sync(value)
DayNight.update(0)
end
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then
break
end
U.wait(1)
end
U.wait(40)
end
local function go(x, y, face, rung)
U.teleport(game, "VIRIDIAN_FOREST", x, y, face or "up")
Pipelines.setLevel("voxel", rung or 5)
Pipelines.setLevel("tiltshift", 0) -- judge the atmosphere, not the blur
settle()
end
ForestAtmos.setting:sync("full")
-- ------- day: gold spears and pollen, three vantages
setTime("day")
go(17, 20)
U.shot(game, ROOT .. "/10_day_17_20.png")
go(16, 20)
U.shot(game, ROOT .. "/11_day_16_20.png")
go(16, 24)
U.shot(game, ROOT .. "/12_day_16_24.png")
-- ------- night: moon rays and fireflies, same vantages
setTime("night")
go(17, 20)
U.shot(game, ROOT .. "/20_night_17_20.png")
go(16, 20)
U.shot(game, ROOT .. "/21_night_16_20.png")
go(16, 24)
U.shot(game, ROOT .. "/22_night_16_24.png")
-- ------- first person, standing in a beam
--
-- The shafts are dealt by seed, so ask the layout where one landed and
-- walk into it rather than guessing a cell.
local shaft
pcall(function()
local map = game.overworld and game.overworld.map
local L = map and ForestAtmos.layoutFor(map)
shaft = L and L.shafts and L.shafts[1]
end)
if shaft then
local cx = math.floor(shaft.x / 16)
local cy = math.floor(shaft.z / 16)
setTime("day")
go(cx, cy + 1, "up", 6) -- the 1ST rung, facing the beam
U.shot(game, ROOT .. "/30_fp_in_beam_day.png")
setTime("night")
U.wait(30)
U.shot(game, ROOT .. "/31_fp_in_beam_night.png")
else
U.log("no shaft landed -- check the layout seed")
end
-- ------- the controls
setTime("day")
ForestAtmos.setting:sync("low")
go(17, 20)
U.shot(game, ROOT .. "/40_low_day.png")
ForestAtmos.setting:sync("off")
go(17, 20)
U.shot(game, ROOT .. "/41_off_day.png") -- must match a pre-feature run
ForestAtmos.setting:sync("full")
ForestAtmos.frozen = false
setTime("day")
U.log("done -- " .. ROOT)
end
+83
View File
@@ -0,0 +1,83 @@
-- Driver: the two sloped-roof drawings in Pallet Town, shot at the orbit
-- rungs, to see what the roof surface wears down a TAPERED flank.
--
-- A tapered column's first drawn row is its silhouette cap, and the cap is
-- outline black. The depth map spends most of the roof's depth above that
-- cap, so a surface clamped onto it paints the outline the length of the
-- slope and the course cycle beats against it -- black teeth marching down
-- the hip. The surface belongs on the column's first PAINTED row instead.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/roof_cap_shots.lua \
-- SHOT_DIR=.scratchpad/roofcap AB_TAG=before lovec.exe .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local ROOT = (os.getenv("SHOT_DIR") or "shots/roofcap")
.. "/" .. (os.getenv("AB_TAG") or "after")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[cap] DRAMATIC_SHAPE mod not loaded")
return
end
local V = handle.lib
local DayNight = V.require("DayNight")
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
require("src.world.OverworldController").rollEncounter = function() return nil end
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
DayNight.setting:sync("day")
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 1
Zoom.applyOptions(game.save.options)
end)
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
U.wait(1)
end
U.wait(30)
end
-- B07 (the gabled house: Red's and Blue's) and B31 (Oak's lab) are the
-- two tapered roof bands standing on one map -- 16 drawn rows and 32,
-- the two groups every other sloped building borrows its band table
-- from. Standing south of each puts the hip end across the frame.
local SCENES = {
{ map = "PALLET_TOWN", x = 5, y = 6, face = "up", label = "reds_house" },
{ map = "PALLET_TOWN", x = 13, y = 3, face = "up", label = "blues_house" },
{ map = "PALLET_TOWN", x = 12, y = 12, face = "up", label = "oaks_lab" },
}
local shots = 0
for _, s in ipairs(SCENES) do
for _, rung in ipairs({ 3, 5 }) do
U.teleport(game, s.map, s.x, s.y, s.face)
Pipelines.setLevel("voxel", rung)
Pipelines.setLevel("tiltshift", 0)
settle()
local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
game.capturePath = path
U.wait(8)
local f = io.open(path, "rb")
if f then f:close() shots = shots + 1
else print("[cap] capture missed: " .. path) end
end
end
print(("[cap] %d shots into %s"):format(shots, ROOT))
love.event.quit()
end
+157
View File
@@ -0,0 +1,157 @@
-- Driver: one STADIUM screenshot per species, every one of the 151 standing
-- on the ENEMY mark in its standby loop.
--
-- stadium_shots.lua photographs a handful of deliberately awkward cases; this
-- is the exhaustive sweep behind it. What it exists to catch is the class of
-- fault that is per-MODEL and invisible to the headless QA pass -- a texture
-- that comes out magenta, a mon standing in the floor or floating over it, a
-- silhouette that is subtly the wrong shape -- none of which is a number
-- stadium_anim_qa.lua can measure.
--
-- SHOT_DIR=mods/DramaticShapeVoxelMod/.claude/stadium_verify \
-- POKEPORT_SPEED=4 \
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/stadium_all_shots.lua \
-- lovec.exe .
--
-- Files land as NNN_species.png in dex order. Run from the PROJECT ROOT.
--
-- ------- everything but the foe is nailed down
--
-- The whole value of 151 shots is that they are comparable, so the staging is
-- identical in every one: same map, same cells, same hour (the day/night
-- cycle would otherwise relight every shot), same rung, and the same small
-- Pokemon on the player's mark -- DIGLETT, chosen because it is the shortest
-- model in the set and so hides the least of the arena behind it. Whatever
-- differs between two of these shots is the foe.
--
-- ------- the model/sprite verdict is the other half
--
-- A species whose pack will not load, or whose animation data the packer
-- declined, does not error: StadiumMon.setSpecies answers false and the mode
-- quietly stands the Game Boy's flat pic on the tile instead. That is the
-- correct behaviour and it is nearly invisible in a screenshot at a glance --
-- which is exactly why it needs counting rather than looking. Stadium.showing
-- is asked for every species and the ones that decline are listed at the end.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or ".claude/stadium_verify"
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")
-- every real species the merged data carries, in dex order
local species = {}
for id, def in pairs(game.data.pokemon) do
if type(id) == "string" and type(def) == "table"
and def.dex and def.dex >= 1 and def.dex <= 151 then
species[#species + 1] = { id = id, dex = def.dex }
end
end
table.sort(species, function(a, b) return a.dex < b.dex end)
game.save.player.name = "RED"
-- ONTO THE MAP FIRST: the model build (if one is owed) is pushed from the
-- mod's update hook on the first frame the overworld is the top state, so
-- waiting for it before teleporting waits forever.
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
if waited > 0 then
U.log(("waited %.1fs for the model build -- %s")
:format(waited / 60, tostring(Install.status.state)))
end
Battles.setting:setValue("stadium", game)
Battles.backSetting:setValue(false, game)
lib.require("DayNight").setting:setValue(os.getenv("DS_TOD") or "day", game)
local have = 0
for dex = 1, 151 do
if Pack.available(dex) then have = have + 1 end
end
U.log(("%d species, %d/151 packs on disk, rung = %s")
:format(#species, have, tostring(Battles.setting:get())))
-- let the neighbourhood's meshes land, so the first fight opens on the
-- real arena rather than the flat fallback
U.wait(90)
local asPic, stuck, missed = {}, {}, {}
for _, s in ipairs(species) do
-- the player's side is a constant (see the header), so the only thing
-- that changes between two shots is the Pokemon being verified
game.save.party = { Pokemon.new(game.data, "DIGLETT", 40) }
local battle = BattleState.newWild(game, s.id, 30)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
-- the wipe and the intro chatter, then 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 and the foe is still
-- sliding in
U.wait(70)
for _ = 1, 40 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(10)
end
if battle.phase ~= "menu" then
stuck[#stuck + 1] = s.id
U.log(("STUCK before menu: %s (phase %s)")
:format(s.id, tostring(battle.phase)))
end
-- and let the send-out beat finish so the mon is standing still in its
-- standby loop rather than mid-arrival
U.wait(45)
-- model or flat pic? asked here, with the fight actually on screen
if not Stadium.showing("enemy") then
asPic[#asPic + 1] = ("%03d %s"):format(s.dex, s.id)
end
local path = ("%s/%03d_%s.png"):format(DIR, s.dex, s.id:lower())
if not U.shot(game, path) then
missed[#missed + 1] = s.id
end
while game.stack:top() and game.stack:top() ~= game.overworld do
game.stack:pop()
end
U.wait(10)
end
U.log(("---- %d shots -> %s"):format(#species - #missed, DIR))
if #asPic > 0 then
U.log(("%d species declined to a model and were shot as FLAT PICS: %s")
:format(#asPic, table.concat(asPic, ", ")))
else
U.log("all species stood as MODELS -- none fell back to a flat pic")
end
if #stuck > 0 then
U.log(("%d never reached the battle menu: %s")
:format(#stuck, table.concat(stuck, ", ")))
end
if #missed > 0 then
U.log(("%d screenshots did not reach disk: %s")
:format(#missed, table.concat(missed, ", ")))
end
U.log("done -- " .. DIR)
end
+7 -3
View File
@@ -44,10 +44,14 @@ return function(game)
{ "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.
-- the five hermite-keyframe species (flags & 8), which the extractor
-- used to misread as packed streams: Exeggutor, Tangela and Magmar came
-- out so garbled the packer declined them to flat pics, and Pidgeot and
-- Dodrio animated garbled. All five must now stand as MODELS in a sane
-- standby loop (see StadiumFragment's animation notes).
{ "EXEGGUTOR", "MAGMAR" },
{ "PIDGEOT", "TANGELA" },
{ "DODRIO", "PIDGEOTTO" }, -- with its packed-stream evolution as control
{ "GYARADOS", "SNORLAX" }, -- the two biggest bodies in the set
}
+56 -16
View File
@@ -1246,6 +1246,22 @@ def profile(sp, t):
wall_h = ground - t["roof_rows"]
ytop = wall_h - 1 + t["slab"]
# The row a column's roof SURFACE may sink to. `top[x]` is the
# silhouette cap -- the black the drawing closes its shape with -- and
# the depth map spends most of a tapered column's depth above it, so a
# clamp onto `top[x]` paints that one outline pixel across the slope
# and the courses beat against it. The surface belongs on the first
# PAINTED row instead, which is the same thing the side faces already
# do when the drawing's own pixel is the outline.
surface_top = []
for x in range(W):
y = top[x]
while (y < t["roof_rows"] and inside(x, y)
and sp["col"][y][x] == BLACK):
y += 1
surface_top.append(y if y < t["roof_rows"] and inside(x, y)
else top[x])
# Recesses: the panes the art seals behind a black frame. Non-black
# pixels of the facade split into components across the black outline;
# a component small enough to be a window or a doorway sinks one voxel.
@@ -1287,7 +1303,8 @@ def profile(sp, t):
# Depth is the PLOT. For a whole-drawing building that is the grid
# itself; `depth` (in tile rows) names it when the grid runs past the
# plot onto ground the drawing merely stands its legs on.
return dict(top=top, wall_h=wall_h, ytop=ytop, recess=recess,
return dict(top=top, surface_top=surface_top,
wall_h=wall_h, ytop=ytop, recess=recess,
inside=inside,
# `depth` names the plot in TILE ROWS, which is the right
# grain for a building. `depth_px` names it in voxels, for
@@ -1697,6 +1714,29 @@ def build_desk_set(sp, pr, t):
return vox
def roof_surface_row(pr, t):
"""Which drawn row the roof surface wears at column x, depth z.
The drawing looks at the roof from the north, so its rows ARE depth: the
rims map one row per voxel and the middle cycles a run whose period is
the course rhythm. A tapered column's band starts lower down the
drawing, and the row it wears is lifted to stay inside it."""
z0, z1 = 0, pr["D"] - 1 + t["front_eave"]
back, front = t["roof_back"], t["roof_front"]
c0, c1 = t["roof_cycle"]
rows, floor = t["roof_rows"], pr["surface_top"]
def depth_row(z):
df, db = z - z0, z1 - z # from the north / the south edge
if df < back:
return df # north rim: the drawing's top rows
if db < front:
return rows - 1 - db # south rim: fascia and eave course
return c0 + (df - c0) % (c1 - c0 + 1)
return (lambda x, z: max(depth_row(z), floor[x])), z0, z1
def build(sp, pr, t):
"""The voxel model. Order is load-bearing: walls, ledge, recesses, then
the roof solid overwrites what it intersects and the walls are trimmed
@@ -1775,17 +1815,7 @@ def build(sp, pr, t):
vox.pop((sx, pr["ground"] - 1 - sy, D - 1), None)
# ---- roof: flat top over the plateau, stepped diagonal ends
z0, z1 = 0, D - 1 + t["front_eave"]
back, front = t["roof_back"], t["roof_front"]
c0, c1 = t["roof_cycle"]
def roof_sy(z):
df, db = z - z0, z1 - z # from the north / the south edge
if df < back:
return df # north rim: the drawing's top rows
if db < front:
return t["roof_rows"] - 1 - db # south rim: fascia and eave course
return c0 + (df - c0) % (c1 - c0 + 1)
surface_row, z0, z1 = roof_surface_row(pr, t)
shade_px = {}
for sy in range(H):
@@ -1797,10 +1827,7 @@ def build(sp, pr, t):
tt = T(x)
for z in range(z0, z1 + 1):
outer = x == x0d or x == x1d or z == z0 or z == z1
# the slope's texture is the drawing's own: clamping into the
# column's first drawn row keeps flank battens running down the
# slope instead of falling off the silhouette
sy = max(roof_sy(z), pr["top"][x])
sy = surface_row(x, z)
for y in range(tt - slab + 1, tt + 1):
if y == tt and not outer:
put(x, y, z, x, sy)
@@ -2043,6 +2070,19 @@ def verify_roof(vox, pr, t):
# drawing's own corner rounding
assert all(ytop - v <= 1 for v in prof), "the flat roof is not level"
# The surface is surface, not outline. `top[x]` is the column's own
# silhouette cap -- the black the drawing closes the shape with, the
# same black measure() already refuses to let stand as a wall's side
# face -- so a surface that samples it promotes a boundary decoration
# into texture. A tapered column's cap sits well down the band, where
# the depth map spends most of the roof's depth above it.
surface_row, sz0, sz1 = roof_surface_row(pr, t)
for x in roofed[1:-1]:
for z in range(sz0 + 1, sz1):
assert surface_row(x, z) != top[x], \
f"roof surface wears the silhouette cap at {x},{z} " \
f"(row {top[x]})"
# every wall column carries roof over it -- and a column the roof never
# reaches carries nothing at all, rather than being silently trimmed away
over = set(roofed)
+11 -11
View File
@@ -229,11 +229,9 @@ def stance(data):
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.
skeleton the reference implementation agreed with; and it does not move
with the animations, so no single clip's excursion decides how big every
other frame of that 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
@@ -249,13 +247,15 @@ def stance(data):
def idle_is_broken(data, idle):
"""Whether this species' standby loop is corrupt in the source data.
"""Whether this species' standby loop is corrupt as extracted.
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.
No species trips this today. Exeggutor, Tangela and Magmar used to:
their animations are hermite keyframes (flags & 8), the extractor read
the flags byte at +0 -- the always-zero high half of the u16 -- and
decoded keyframe tables as packed streams, which threw bones hundreds
of units off the body. The detector stays as the guard against the
next extraction bug: played, a broken idle looks like a Pokemon coming
apart, and the mod would rather show the sprite fallback.
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