diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8057cac8c..c085ea5ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -367,6 +367,8 @@ jobs: # notarize separately below so it uses secret credentials, not a # login-keychain profile. bash scripts/build.sh mac --version "${{ needs.version.outputs.version }}" --no-notarize + [ -f .bazinga/work/Gen2Recomped.app/Contents/Resources/translation/rom-translate ] \ + || { echo "::error::rom-translate is missing from the macOS app"; exit 1; } - name: Notarize & staple macOS app if: vars.APPLE_SIGNING == 'true' @@ -431,18 +433,97 @@ jobs: # bills macOS minutes at 10x), and -- the point of the split -- a broken # Xcode image or a disabled Homebrew cask can no longer stop the Windows # build from happening. + # The offline translator is a PyInstaller one-file build, and PyInstaller + # does not cross-compile: rom-translate.exe can only be made on Windows. + # The macOS and Linux jobs build their own helper in place; the win64 job + # runs on Linux, so it had none -- build.sh skips a missing helper without + # a word, and every Windows release shipped "Offline translator is missing + # from this build". Build it here and hand it over. + windows-translator: + name: Windows offline translator + needs: version + runs-on: windows-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Build offline translation helper + run: | + python -m pip install -r tools/translation/requirements.txt + python tools/translation/build_desktop.py + + - uses: actions/upload-artifact@v7 + with: + name: rom-translate-win + path: translation/rom-translate.exe + if-no-files-found: error + retention-days: 1 + + # The Linux helper, for both architectures, built inside Debian bullseye: + # glibc 2.31 is the oldest the AppImages promise (scripts/linux-arm64/ + # Dockerfile), and a PyInstaller binary carries the glibc of the machine it + # was built on -- ubuntu-latest's would not start on a bullseye or 20.04 + # system. arm64 feeds the arm64 AppImage and the RG34XXSP port, neither of + # which had any helper at all. + linux-translator: + name: Linux offline translator (${{ matrix.arch }}) + needs: version + strategy: + matrix: + include: + - arch: x86_64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + container: python:3.12-slim-bullseye + steps: + - uses: actions/checkout@v7 + + - name: Build offline translation helper + run: | + set -eu + # PyInstaller reads shared-library dependencies with objdump + apt-get -o Acquire::Retries=3 update + apt-get install -y --no-install-recommends binutils + python -m pip install -r tools/translation/requirements.txt + python tools/translation/build_desktop.py + ls -l translation/rom-translate + + - uses: actions/upload-artifact@v7 + with: + name: rom-translate-linux-${{ matrix.arch }} + path: translation/rom-translate + if-no-files-found: error + retention-days: 1 + windows: name: Windows (win64) - needs: version + needs: [version, windows-translator] runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + - uses: actions/download-artifact@v7 + with: + name: rom-translate-win + path: translation + - name: Build Windows (win64) run: | set -euo pipefail bash scripts/build.sh win --version "${{ needs.version.outputs.version }}" + # build.sh packs the helper only if it is there; a release must have it + - name: Verify the offline translator is packed + run: | + set -euo pipefail + listing="$(unzip -l dist/win/Gen2Recomped-win64.zip)" + grep -q "translation/rom-translate.exe" <<<"$listing" \ + || { echo "::error::rom-translate.exe is missing from the Windows zip"; exit 1; } + - name: Stage the Windows archive run: | set -euo pipefail @@ -475,18 +556,17 @@ jobs: # business needing it. linux: name: Linux (x86_64 AppImage) - needs: version + needs: [version, linux-translator] runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: actions/setup-python@v6 + - uses: actions/download-artifact@v7 with: - python-version: "3.12" - - name: Build offline translation helper - run: | - python -m pip install -r tools/translation/requirements.txt - python tools/translation/build_desktop.py + name: rom-translate-linux-x86_64 + path: translation + - name: Mark the translator executable + run: chmod +x translation/rom-translate # Never run `apt-get update` unconditionally: GitHub's # azure.archive.ubuntu.com mirror stalls for 10-60 minutes at random @@ -513,6 +593,8 @@ jobs: run: | set -euo pipefail bash scripts/build.sh linux --version "${{ needs.version.outputs.version }}" + [ -f .bazinga/work/linux-appdir/translation/rom-translate ] \ + || { echo "::error::rom-translate is missing from the Linux AppImage"; exit 1; } - name: Stage the Linux archive run: | @@ -760,12 +842,20 @@ jobs: # on Linux is both faster and independently failable. rg34xxsp: name: Anbernic RG34XXSP (PortMaster) - needs: version + needs: [version, linux-translator] runs-on: ubuntu-latest continue-on-error: true steps: - uses: actions/checkout@v7 + # the handheld is aarch64: the arm64 helper, not this runner's + - uses: actions/download-artifact@v7 + with: + name: rom-translate-linux-arm64 + path: translation + - name: Mark the translator executable + run: chmod +x translation/rom-translate + # zip, unzip, curl and file are ALL preinstalled on the ubuntu-latest # runner image, so this step only has to cover a future image that # drops one of them. Never run `apt-get update` unconditionally here: @@ -793,7 +883,7 @@ jobs: set -euo pipefail # Payload comes from scripts/pack_love.sh, same as every other # platform, so this pack cannot drift from the desktop build. - bash ./build-rg34xxsp.sh --version "${{ needs.version.outputs.version }}" + REQUIRE_TRANSLATOR=1 bash ./build-rg34xxsp.sh --version "${{ needs.version.outputs.version }}" - name: Stage the pack run: | @@ -985,12 +1075,19 @@ jobs: # ------------------------------------------------------------------ arm64 arm64: name: Linux ARM64 AppImage - needs: [version, payload] + needs: [version, payload, linux-translator] runs-on: ubuntu-24.04-arm continue-on-error: true steps: - uses: actions/checkout@v7 + - uses: actions/download-artifact@v7 + with: + name: rom-translate-linux-arm64 + path: translation + - name: Mark the translator executable + run: chmod +x translation/rom-translate + - uses: actions/download-artifact@v7 with: name: game-love diff --git a/build-rg34xxsp.sh b/build-rg34xxsp.sh index 10937434b..af31f7b0f 100755 --- a/build-rg34xxsp.sh +++ b/build-rg34xxsp.sh @@ -127,6 +127,20 @@ for required in tools/rom_manifest_gold.json tools/rom_manifest_crystal.json \ [ -e "$GAME_SRC/$required" ] || fail "staged lovegame/ is missing $required" done +# The offline translator (aarch64 only -- this is the handheld's CPU). +# worker.lua reads translation/rom-translate out of the game and copies it to +# the save directory before running it. +if [ -f "$ROOT/translation/rom-translate" ]; then + if file -b "$ROOT/translation/rom-translate" | grep -q "aarch64"; then + mkdir -p "$GAME_SRC/translation" + cp "$ROOT/translation/rom-translate" "$GAME_SRC/translation/" + else + say "translation/rom-translate is not aarch64; the port ships without it" + fi +fi +[ -n "${REQUIRE_TRANSLATOR:-}" ] && { [ -f "$GAME_SRC/translation/rom-translate" ] \ + || fail "REQUIRE_TRANSLATOR is set and no aarch64 translator was packed"; } + # Portable marker: saves + ROM cache live next to the game on the SD card. : > "$GAME_SRC/portable.txt" diff --git a/docs/gen4-platinum.md b/docs/gen4-platinum.md index 96451d7ba..354ccf239 100644 --- a/docs/gen4-platinum.md +++ b/docs/gen4-platinum.md @@ -38250,3 +38250,194 @@ Battle Points. `sTrainerScoreIncrements`. - Both Frontier harnesses print the records at the end. A Castle lobby run shows victories, CP received and the round-completion score adding up. + +## Battle screens, touch screens, Pokétch, evolution, and the particle mirror fix + +### Battle (src/battle/Gen4Battle.lua, src/ui/Gen4BattleParty.lua, src/import/Gen4BattleArt.lua) + +**Top screen.** +- "What will X do?" (battle strings 921, or 922 in the Great Marsh) now shows in the message box while the menus are on the bottom screen. +- Healthboxes gain status icons at the cartridge's VRAM offsets and, for wild Pokémon already caught, the caught-ball mark. +- Nicknames use the box palette's colours 14/2. + +**Bottom screen.** +- The cursor is four pulsing red corners. +- The player's party balls appear at (12+19i, 13), and the foe's at (246−12i, 9). +- Trainer battles show the `party_gauge.c` ball capsules as each side's Pokémon comes out. +- Double battles use the cartridge's target-select buttons. + +**Battle party list.** It is the `pl_b_plist_gra` list (six panels, then SHIFT / SUMMARY / CHECK MOVES), shown when the bottom screen is up. + +Harness: `tools/gen4_battle_ui_harness`. + +### Touch and backlog (Summary, Bag, PC, Trainer Card) + +**Summary.** +- Its page buttons take touch, with press faces and the tap circle. +- CONDITION, CONTEST MOVES and RIBBONS stay hidden until flag 0x978, the Contest Hall visit, is set. +- The condition graph grows over four frames, then flashes. + +**Bag.** +- Pocket buttons and the dial take touch; dragging the dial scrolls the list. +- A opens the per-pocket action menu: USE, GIVE, TRASH, REGISTER and CHECK TAG. +- TRASH asks how many, then YES/NO. +- SELECT picks an item up to move it, showing the moving bar. + +**PC.** +- Changing box slides the wallpaper ring over 30 frames. +- JUMP opens the thumbnail popup. + +**Trainer Card.** +- It flips over 17 frames. +- The lid opens from the touch button. +- Badges can be polished, with dirt levels and sparkles. +- A saved signature is drawn in its box. + +Check: `gen4_touch_screen_parity_check` (44). + +### Pokétch, evolution, start menu, Town Map + +**Pokétch.** +- Every app is redrawn to the decomp, using its own tile patches, sprites and animation clocks. +- Pokémon icons are in the LCD's four shades. +- The shutter uses tile 164 when switching apps. +- Touch areas use the cartridge's `TouchScreenRect`s. + +**Evolution.** It follows `evolution.c`'s timeline: the letterbox, the forms trading places as the scale step doubles from 8 to 64, the emitters from `shinka_demo_particle.narc`, and the B-button cancel with its white flash. + +**Start menu.** Icons use their palette rows and swell, then wiggle when selected. Labels are left-aligned at +28, and the menu reopens on the last option chosen. + +**Town Map.** Unlocked hidden locations are drawn. + +### Fix: move effects drew rings as four notched squares + +`SPLTextureParam` bits 14–15 are the texture's FLIP S/T. A flipped repeat mirrors every other copy, which is how a quarter-circle texture tiled 2×2 becomes a ring or orb. `Gen4Particle.textures` now reads the bits, the importer saves them on each particle record, and `Gen4Battle.drawParticles` uses mirrored wrap. 764 of the 1,706 move-effect textures set it. + +An existing cache is patched with `tools/gen4_particle_flip_patch `. + +## The battle bag, the party list's pages, and double battles on the top screen + +### The battle bag (`src/ui/Gen4BattleBag.lua`) + +BAG now opens the cartridge's bottom-screen bag (`battle_bag*.c`) when the bottom screen is up and the cache has the art; otherwise the field bag opens as before. The rule is `Gen4Battle.bagScreenId`, which works like `partyScreenId`. + +- **Menu.** It shows the four battle pockets (HP/PP RESTORE, STATUS HEALERS, POKé BALLS, BATTLE ITEMS), the "Item used last" bar with that item's icon, and CANCEL. When no item is remembered, the bar is disabled. +- **Pocket.** It shows six items a page, each with its icon, name and "xN". Below are the page arrows, which wrap and are disabled for a single page, the pocket's name and "n/m". +- **Use.** It shows the item's icon, name, count and description, with the USE bar in the pocket's colour. +- **Pockets.** These follow `BattleBag_Init`. Each bag item is filed under every pocket its battle-pocket mask names, in field-pocket order and then bag order. +- **Bag cursor.** The pocket, each pocket's page and position, and the last item used live on the game, as the cartridge's `BagCursor` does. An item no longer carried is forgotten. +- **Refusals.** `TryUseItem`'s refusals show in the message box: a ball with two foes standing, a foe out of sight, or a foe behind a substitute. +- **Using an item.** This goes through `BagMenu.useItem`, so it does what the field bag does. An item that needs a target opens the battle party list in its "Use on which Pokémon?" mode, through a small hook in `BagMenu`'s `pickTargetAndUse` (`BattleState:gen4ItemTargetScreen`). +- **Buttons.** Pressing a button shows PRESSING, then PRESSED, and the button's words and icon lift 4 px, then 2 px. + +Positions, windows, touch rects and cursor tables are the decomp's. Art: `gen4_battle_art` `bbag_*`; ink: `gen4_battle_anims.bagInk` (palette rows 0 and 4). + +### The battle party list's other pages (`src/ui/Gen4BattleParty.lua`) + +SUMMARY and CHECK MOVES are now the cartridge's own bottom-screen pages, not the field summary: + +- **Summary.** Level, NEXT LV with the EXP bar, HP with its bar, the five stats, the ability and its description, the held item, and the prev/next buttons. Prev/next skip eggs. +- **Check moves.** Four move buttons, each with its type icon and PP. +- **Move summary.** The move's type and category icons, PP, power, accuracy, category and description, plus the four small move buttons. + +Two new modes: + +- **`useItem`.** The list asks "Use on which Pokémon?". An ETHER-type item then opens RESTORE WHICH MOVE?, and the move goes to the bag flow's `onMove`. +- **`learnMove`.** It shows the four moves and the move to learn, then FORGET or CANCEL. An HM cannot be forgotten ("HM moves can't be forgotten now."). `BattleState:gen4LearnOnBottom` opens it from the level-up move-learn question in place of the field's list. + +All pages share `src/ui/Gen4SubMenuCursor.lua`, the port of `battle_sub_menu_cursor.c`, including its "go back where you came from" links. + +### Small things + +- **Hidden cursor.** The cursor is hidden until a key is pressed (`IsCursorVisible` / `BattleSystem_MenuKeys`). That first press only shows it, and a tap hides it again. The flag, `battle.gen4CursorActive`, is shared by the battle's own menus, the bag and the party list. +- **Target names.** The gender symbol uses `{COLOR 3}` / `{COLOR 4}`, which is palette 6 colours 7/8 for ♂ and 9/10 for ♀. The foes' slots now show their Pokémon icon (`sTargetSelectIconPositions`). The icon animates at `shared_anim_32k`'s speed for its HP colour, and holds still when the Pokémon has fainted. +- **Party balls.** A ball whose Pokémon is 87% or more of the way to its next level wobbles (`SysTask_AnimatePartyBalls`). + +### Double battles on the top screen + +- **Battlers.** All four battlers are drawn at battler types 2–5 (position p is type p + 2), far side first. +- **Healthboxes.** Four boxes are drawn. The foe's two use the solo art at (64,16) and (58,45); the player's two use `healthbox_player_doubles` on the short cell at (192,103) and (198,132). + +### Extraction and checks + +- **Re-extract.** `love tools/gen4_art_extract battle` adds 65 pictures: the bag's three views and its buttons, the six party pages and their buttons, and the EXP fills. It also adds `bagInk` and `partyInk13` to `gen4_battle_anims`. The importer (`RomExtractorGen4:battleArt`) writes the same thing, and no new cache module was needed. +- **Harness.** `tools/gen4_battle_ui_harness` has new states: `bag_*`, `party_summary`, `party_moves`, `party_movesum`, `party_item`, `party_restore`, `party_learn`, `party_confirm`, `doubles`, `doubles_target` and `balls_wobble`. +- **Check.** `tools/gen4_battle_bag_check.lua ` runs 75 checks. They cover the pockets, paging, the cursor, touch, "Item used last", the refusals, the party pages' modes and the wobble. + +### Known gaps + +- Neither screen has the palette fade on entering or leaving. +- The catch tutorial's scripted bag is not ported. +- The CONTEST STATS toggle on the learn pages is not ported. +- The party list does not animate the HP or PP filling after a restore item. The item flow's own messages say what happened instead. +- In a double battle, move effects still aim at the singles positions. + +## Sound effects, the PC and Town Map bottom screens + +### Sound effects (src/core/Sound.lua, src/audio/NitroAudio.lua, src/import/Gen4Sdat.lua) + +The audio cache already held all 1,013 of the ROM's named sequences; the +screens simply never played them. + +**API.** `Sound.playSe(data, key, opts)` accepts any of: + +- a ROM symbol (`SEQ_SE_DP_CARD5`); +- an id; +- a decomp constant (`SE_CONFIRM_sseq_3`), resolved through shared sequence + files plus a short table for the files the decomp renamed. + +**Playback.** It plays through NitroAudio and honours the SDAT's SE players: +each sequence's player and its maximum, and priority stealing. `opts.pitch` +and `opts.handle` set pitch and a separate handle. `isSePlaying` and +`stopSe` go with it. + +**Wired from each screen's C:** + +- the contest (dance, acting, visual and results); +- Poffin cooking and the Poffin Case; +- the Mart; +- the Pokétch and its apps; +- mining; +- the Trainer Card, including the badge chimes' four-player rotation and + pitch; +- the Bag, Summary and naming screen; +- Options, the start menu, the Pokédex and evolution. + +The Hall of Fame plays no effects on the cartridge. + +**Not done.** The feeding cutscene's "pinch" cry when a Pokémon dislikes a +Poffin is not played, because `Sound.playCry` has no pinch variant. NitroAudio +still ignores ADSR envelopes, so effects are close to the DS but not +identical. + +Check: `gen4_sound_effects_check` (2,286). Tool: `tools/gen4_sound_effects_render` +writes effects to WAV. + +### PC storage bottom screen (src/ui/Gen4BoxMenu.lua, src/import/Gen4BoxArt.lua) + +The bottom screen is ov19's layers: + +- the backdrop, the cover and the two dial faces, scrolled by the decomp's + offset tables; +- the corner buttons; +- the BOX dial, a 144-point arc of box thumbnails; +- the MARKINGS dial with its filter. + +Filtered icons fade to 6/16. MOVE ITEMS shows each Pokémon's held item. + +COMPARE mode (`g4_storage` mode 4, PC_MODE_COMPARE) has a battle-stats page, +a contest pentagon page and a moves page. + +### Town Map bottom screen (src/ui/Gen4TownMap.lua, src/import/Gen4TownMapArt.lua) + +**Zoom button.** Pressing it plays the press and shockwave, then a fade, and +opens the zoomed GUIDE MAP. The map scrolls to the player, with a signpost +(the map or arrow style, or a landmark) and each block's flag-gated area and +landmark descriptions. + +**History dots.** `save.gen4MapHistory` is a six-entry ring +(`overworld_map_history.c`). It is pushed on every outdoor step that changes +matrix cell, from `onStepComplete`. The town map shows the dots oldest to +newest, one every 26 ticks. + +Check: `gen4_pc_town_map_bottom_check` (33). diff --git a/docs/hgss-bw-import.md b/docs/hgss-bw-import.md new file mode 100644 index 000000000..2802c5c31 --- /dev/null +++ b/docs/hgss-bw-import.md @@ -0,0 +1,138 @@ +# HeartGold, SoulSilver, Black and White: import status + +All four cartridges are registered (`src/core/GameVersion.lua`) as dual-screen +games with the same second-screen modes as Platinum, single screen included. +They stay `importable = false` ("COMING SOON") until they are playable; set +`POKEPORT_UNLOCK=heartgold,soulsilver,black,white` (or `all`) to import one in +development. + +## HeartGold / SoulSilver (generation 4, family `hgss`) + +These games go through `RomExtractorGen4` with the per-version differences in +`src/import/Gen4Versions.lua`. Sources are pret/pokeheartgold and the +cartridges themselves. + +| Stage | Status | Notes | +|---|---|---| +| text, species, moves, items, trainers, trainer lines | ported | numbered archive paths from `Gen4HgssFiles` (generated), text banks found by reading | +| encounters | ported | HGSS's own 0xC4 record (`Gen4Encounters.parseHgss`): one level per slot, a whole species table per morning/day/night, fishing 40/30/15/10/5, rock smash 80/20 | +| maps | ported | 540 headers in HGSS's layout (`Gen4MapHeaders.parseHgss`), read from the BLZ-compressed ARM9 (`NdsBlz`); land chunks' extra sound section skipped | +| events, regions | ported | Platinum's format; every file consumed to the byte, no dangling warps | +| scripts, link | ported | 853-command table generated from `asm/macros/script.inc` (`tools/gen4_hgss_scrcmd_gen.py`), with HGSS's common-script bands. 96.3% of 181,648 instructions lower through Platinum's VM via `Gen4HgssScriptAliases`; the rest are reported as unimplemented. | +| camera | ported | HGSS's own 17 field cameras (`Gen4Camera.HGSS_TYPES`, decoded from the overlay table) under Platinum's camera code, tilt ladder and controls | +| terrain | ported | all 676 land-chunk models, 106 texture sets (/a/0/4/4), each map's set and indoor/outdoor flag from HGSS's own area record (`Gen4HgssArea`, read from overlay 1's loader) | +| building models | ported | outdoor /a/0/4/0 (340) and indoor /a/1/4/8 (222), each with its own textures; `Gen4Ground` picks the indoor set for rooms | +| overworld sprites | ported | 832 sheets; sprite id → archive member from pret's table (`Gen4HgssObjectGfx`, generated by `tools/gen4_hgss_sprites_gen.py`); HGSS's 17 animation tables (`Gen4HgssFacings`) are byte-identical to Platinum's named ones; var sprites resolve as on Platinum: `map_object.c` maps ids 101–117 to `VAR_OBJ_0 + (id − 101)`. An earlier HGSS-only base of 100 put every friend one var out. Objects are re-dressed once a map's entry script has filled the var (`OverworldState:refreshGen4VarSprites`) | +| boot location, blackout and fly points | ported | `Gen4HgssField`: the bedroom start (`T20R0202` 6,6, facing down) and all 30 `sSpawnMaps` rows (18 bytes each), found together in the ARM9 and agreeing with each other | +| player sheets | ported | walk / bike / surf / field move / fishing for both players, from `player_avatar.c`'s state switch | +| area lights | ported | `/data/arealight.narc` is byte-identical to Platinum's | +| fonts, window frames | ported | font members named by their match to Platinum's (`Gen4Versions.MEMBER_NAMES`); `/a/0/3/8` is Platinum's frame archive with one member inserted, which matches the loader's frame+2 / frame+26 arithmetic | +| constants, type chart | ported | `Gen4HgssConstants`. The type chart is found in battle overlay 12 now that `NdsRom` unpacks BLZ overlays (127 of HGSS's 129 are packed). Also builds the curves, the TMs and HMs (HM05 Whirlpool), 16 badges and the new game's 143 SetFlags. Trades are built (13, from /a/1/1/2 and bank 200); marts and berries are not yet built. | +| Pokémon battle sprites | ported | all 493 species: 2,641 images (front/back, shiny, female, 78 forms), none missing | +| trainer pictures | ported | `/a/0/5/8`, five members per class like Platinum's, but descrambled in Diamond/Pearl's order (`Gen4Graphics.decryptSpriteDP`, per `UnscanPokepic` in pokepic.c); 129 classes on all 738 trainers | +| terrain heights | ported | all 676 chunks | +| battle backdrops | ported | `Gen4HgssBattle`: the 23 backdrops (Platinum's order, Johto's Elite Four rooms) × 3 times × colour/grey from `/a/0/0/7`. Same member arithmetic as Platinum except the palette base, 0xB0, per overlay 12's loader. | +| battle platforms, health boxes, HUD | not yet | `/a/0/0/8`, looked up by name like Platinum's `pl_batt_obj`. Held back while the Platinum battle-art work is in progress. | +| starter choice | ported | `ChooseStarter` opens Elm's turntable (`src/ui/Gen4HgssStarterSelect.lua`). It is drawn from the cartridge's own `choose_starter_main_res` (/a/0/8/2, the models stage's `hgssStarter` set), with choose_starter_app.c's camera (out −49.57° / 100, in −30.76° / 60 over 8 steps), its 120° turntable steps, the rock and open animations, and bank 190's words. As in `CreateStarter`, the choice goes straight into the party at level 5; the lab script then reads slot 0. `SetStarterChoice` / `GetStarterChoice` read and write the save's starter field. Choosing Chikorita in Elm's Lab gives it, then plays the lab's "received" line. `PlaceStarterBallsInElmsLab` stands `labo_mb` (room model 0x8D) on the machine at the cartridge's three points. The count follows its rule: none after Falkner's TM51, 1 after meeting the passer-by boy, 2 once you have a Pokémon, else 3. It goes through the map def's `gen4RuntimeProps`, which `Gen4Ground:objectsFor` merges like Platinum's healing balls; any script command can now stand a prop that way. | +| texture edges | ported (all Gen 4) | the material's texture parameter (+0x14) is now read. Bits 16–19 give repeat or mirror per axis and are packed as `shape.wrap` when not plain repeat. The starter balls' highlight is clamped, and repeated it had tiled every ball in white dots. Platinum gets the same on its next re-import (310 of 2,013 shapes, mostly untextured building faces). | +| intro, title, menus' art, area popup, move animations, cries | not yet | written to `gen4_version.notPorted`. Until then the boot screens are Platinum's Gen 4 screens. | + +## Seeing it + +``` +CACHE_ROOT=G:/Gen2Recomped VERSION=heartgold ROM="./Pokemon - HeartGold Version (USA).nds" \ + love tools/gen4_hgss_import_harness +INSTALL=G:/Gen2Recomped/heartgold VERSION=heartgold MAP=T20 X=14 Y=14 \ + love tools/gen4_hgss_overworld_harness +``` + +The first harness imports into the version's own folder (`heartgold/`, the +launcher's layout); the second mounts that folder and runs the real overworld, +writing `ow_.png` to the `hgss_ow` save folder. Rendered so far: + +- New Bark Town: ground, Elm's Lab and houses, NPCs and the rival by the lab + window, under HGSS's own default camera (48.7°) +- the player's bedroom: indoor building set and the indoor camera +- Route 29: trees, grass and flowers + +All three are drawn by the same renderer and camera controls as Platinum. + +`HARNESS=gen4_battle_harness` runs the battle harness on the same mounted +install. A wild battle draws HGSS's Pokémon sprite on its backdrop. The +platforms and HUD are still missing. + +Scripts run as well. Entering the house downstairs (`T20R0201`) plays Mom's +opening scene from the map's own frame table, and her line prints in HGSS's +font inside HGSS's message box. + +Elm's Lab (`T20R0101`) plays through. Its row-10 trigger (scene var 0x4108 = 0) walks the player to Elm. He explains his research and turns to the email, then walks back and releases. `GetFriendSprite` dresses the friend as Lyra or Ethan, and the text names Lyra for a boy. The machine shows its starter balls, and choosing one runs Elm's turntable and puts the starter in the party. The follower commands (`ScrCmd_605`–`609`) are no-ops, as they are on the cartridge whenever no Pokémon walks behind the player. Leaving the lab, the assistant's row-11 trigger hands over the Potions. On Route 29 Lyra and her Marill walk up, and `CatchingTutorial` runs the demonstration: Lyra's Marill L5 against a wild Rattata L2, from BattleSetup_New_Tutorial. Cherrygrove's doors (`ScrCmd_307`–`311`) are Platinum's door-animation family under other numbers. `tools/gen4_hgss_overworld_harness` takes `SET_VARS` / `SET_FLAGS` / `CLEAR_FLAGS` to start a scene from its story state. After the starter, the lab names it ("RED received CHIKORITA…"). `bufferpartymonspecies` takes a party slot, per pokeplatinum's scrcmd_strings.c; it had been read as a species id, which printed "-----" on Platinum too. `NicknameInput` opens the nickname screen under the naming bank's own title ("CHIKORITA's nickname?", bank 249; 422 on Platinum, which also showed "YOUR NAME?" until now). `NameRival` opens it for the rival ("This boy's name?"), stores `save.player.rival` and answers 1 on an empty name, so the lab asks again. Mr. Pokémon's house (`R30R0201`) plays through: he walks to the door, gives the Mystery Egg, and Oak gives the Pokédex and registers his number. The Pokégear is save state (`save.hgssPokegear`: cards, phone numbers below 75, map level), and `CheckRegisteredPhoneNumber` reads it. Mom's savings answer `CheckBankBalance` / `BankOrWalletIsFull`. `BankTransaction`'s amount dial is not built, so it answers cancelled, the script's own exit. Also aliased: `ScrCmd_795` / `796` money box, `HasEnoughMoneyVar`, `WaitButtonOrDpad`, and `ScrCmd_573` (Platinum's `UnlockMysteryGift`, now lowered on both as a save flag). `GetPersonCoords` reads an object's matrix position, answering 255 / 255 when it is absent. **Encounters and trainers:** Route 29's grass rolls from HGSS's own tables (Pidgey from the 25% grass rate; `ENC_FORCE=1` / `ENC_DEBUG=1` on the overworld harness pass and log the rolls). Route 30's trainers spot the player and battle through the shared trainer script (scr_seq_0953). After a win, that script asks `GoToIfDefeated` before running `SetTrainerFlag` itself. So on HGSS `checkwonbattle` no longer marks the trainer early; Platinum's scripts never set the flag, so it still does there. Marking early had sent every first win down the rematch / phone-gift branch. `LoadPhoneDat` answers 255 (no phone entry) while the phone is not built, and `ScrCmd_574` is `getmovementtype`. Object labels now come from HGSS's own sprite names; they had been Platinum's, so Route 30's apricorn trees read `wall_blocking_rotoms_room`. **Violet City:** the in-game trade runs: the man in T22R0601 takes a Bellsprout and gives Onix. HGSS's trade commands are Platinum's five, renamed, plus the party menu and its answer. The table is `/a/1/1/2`: 13 records of 0x54 bytes whose first twenty u32s are Platinum's layout field for field. The names come from bank 200, the 13 nicknames and then the OT names (`NPC_TRADE_OT_NUM` = id + 13; the Onix's OT is Rudy). The aide in the Pokémart (T22FS0101) gives `GiveTogepiEgg`'s Egg: Togepi, level 1, Growl / Charm plus Extrasensory, as scrcmd_pokemon_misc.c builds it. Cameron's photos go into `save.hgssPhotoAlbum` (36 at most); the album screen is not built. `MoveWarp` and `ScrCmd_379` are Platinum's `setwarpeventpos` and `gettimeofday`. **Falkner's gym:** the lift works (field/gymmick/violet.c). `VioletGymInit` puts build model 111 down at (0xF8, 32, 0x148) and pins the 3 × 3 tiles (14–16, 19–21) to its height. `VioletGymElevator` carries both to 496 and back, 16 units a frame, with the state in `save.hgssGymmick`. Until Sprout Tower is cleared, the gym guy stands on the trigger tile (`FLAG_HIDE_VIOLET_GYM_GYM_GUY_AFTER_SPROUT`), as on the cartridge. Two fixes came out of it. **`Gen4Ground:livePass` always draws the player's own chunk row**: the camera follows the player's raised position, and 464 units up the view's top edge left the chunk-row range entirely, so the gym drew black. **Trainer lines read bank 728 on HGSS** (trainer_data.c), not Platinum's 617. Falkner battles from his platform and gives the Zephyr Badge (`BADGE_ZEPHYR`), clearing the gym's other trainers, then points the player to Azalea Town. The gym's top floor draws because `followCamera` now hands `Gen4Ground` the target's ground position (`cameraFeetX` / `cameraFeetZ`) in every camera mode. Anything worked back from the view is rows off the floor on a map this tall, and three things now use the real feet: the chunk rows the live pass draws, where the 3D / tilted / third-person camera stands (it had been 230 units under the walkway), and the height the default camera's perspective spread pivots about (anchored at 0, it drew the top floor at three times its size). **Baked lighting:** HGSS's map geometry is lightmapped (`_lm` materials). Its display lists issue `COLOR`, not `NORMAL`, so the DS never lights it and the vertex colours are its lighting. The decoder now records whether a vertex was given a normal (`noNormal`), and a shape with none is packed `unlit` and left out of the area-light shade. Its resting normal (0, 0, 1) had been shaded as a wall facing the camera, and Falkner's floors drew at about a third of their brightness. Platinum's terrain issues `NORMAL` and renders pixel-identically. **Bugsy's gym** (T23GYM0102): the Spinarak carts work (field/gymmick/azalea.c, transcribed in `src/world/HgssAzaleaGym.lua`). `AzaleaGymInit` puts the four carts (build model 118) on stations 0, 1, 2 and 7 and both switches off, as on every entry. A station's trigger runs `AzaleaGymSpinarak n`: the player steps on, and the cart runs its route for the current switch state at 2 units a frame, stopping each axis at its node as the cartridge does. Stations 3, 4, 5, 9, 10 and 11 run their route backwards, with the player a tile below the cart. The player is then put on the last node's tile and steps off. From station 0 that is 209 frames, landing on (9, 24). `AzaleaGymSwitch` flips its bit in `save.hgssGymmick`. The levers' own animations, the end-of-ride camera shake and the ride's sound are not built. Bugsy battles and gives the Hive Badge. His TM89's "put it in the Pocket" line reads HGSS's pocket names (msg_0226, with the colour code and glyph stripped); it had read Platinum's bank 395 and printed a gap. **Ilex Forest's Headbutt tutor** works on Platinum's tutor commands. HGSS's tutor data now imports: `sTutorMoves` (52 rows of move, BP cost and tutor NPC, found in its overlay by the first two rows) and `fielddata/wazaoshie/waza_oshie.bin` (64 bits per moveset). `ScrCmd_656` is "can learn from NPC 3"; `PartySelectUI`, `PokemonSummaryScreen` / `GetMoveSelection` (the forget-a-move pick), `SetMonMove`, `MonHasMove` and `CountMonMoves` lower onto the existing commands. **Apricorns** (`src/world/HgssApricorns.lua`, from apricorn_tree_sys.c and the box code): each tree object carries its index (param 0 of the `apricorn_tree` band; all 31 import) and shows its fruit's sprite (262 bare, 263–269 by colour). Shaking it gives one Apricorn into `save.hgssApricorns` (99 at most) and leaves it bare until the next day. Kurt's house runs: `ScrCmd_739` opens the box as a list and a quantity box, he keeps the colour and count, and the next visit gives that many balls (Red Level, Yellow Moon, Blue Lure, Green Friend, Pink Love, White Fast, Black Heavy). The box screen's own art and the tree's shake animation are not built; the shake waits its 32 frames. The HGSS overworld harness takes `APRICORNS="3=5"` and `KURT="2=3"`. `ScreenShake ampX, ampY, count, period` shakes the camera's look-at target by amp × sin over each period, `count` times, and the script waits (ov02_02246548). The Headbutt man's demonstration in Ilex Forest (trigger at (37, 42)) plays with it. **Goldenrod and Routes 34–36:** Whitney battles, cries without giving the badge, the lass's scene at (13, 11) sets flag 0xB7, and talking to Whitney again gives the Plain Badge and TM45. `ScrCmd_522` is the hour (her postgame schedule). Route 36's Sudowoodo: on HGSS `CheckBattleWon` is `IsBattleResultWin`, true for anything but a loss or draw. The port had read it as "won" only, so catching or fleeing white-outs; now a catch sets `FLAG_CAUGHT_SUDOWOODO`, fleeing clears the road and a loss white-outs. `GetStaticEncounterOutcome` gives the outcome code (win 1, lose 2, draw 3, caught 4, fled 5), and `StaticWildWonOrCaughtCheck` is true unless the Pokémon was beaten or caught. Platinum's `checkwonbattle` is unchanged. The Department Store lift names floors from msg_0191 (`BufferDeptStoreFloorNo`), its floor window and car animation are no-ops as on Platinum's lift, and choosing 6F sets the exit to T25R1006. Also lowered: `CountAliveMons`, `PartyCountNotEgg`, `CheckDaycareEgg`, `GetItemQuantity` (which also lowers Platinum's one use of the same name), `AddMoney`, `SetObjectMovementType`, `ScrCmd_582` (the special spawn for Dig / Escape Rope), and the follower's `ScrCmd_596` / `600`. The overworld harness takes `SHOW_FLAGS` and `SHOW_SAVE` with `PARTY_DETAIL`. **Morty's gym** (T27GYM0101; field/gymmick/ecruteak.c, `src/world/HgssEcruteakGym.lua`): `EcruteakGymInit` fogs the room to black, as GymmickInit_Ecruteak does (fog offset 0, every density step at 127, colour 0). The fog is drawn per material: polyAttr bit 15 is the DS's fog-enable, imported as `noFog` where it is clear, and a fogged map (`def.gen4Fog`) bakes every other shape in the fog colour. People are billboards and stay visible, so only the people show, as on the cartridge. The 15 pit rectangles (coord script 002) drop the player back to the entrance. Each medium (objects 2–5) carries model 128 (`gym04spot`), placed on her every frame through `def.gen4PropFollow`, which also covers her walk up to the player. Its texture is a black darkness mask, so under the fog it shows nothing. `ExtinguishEcruteakGymCandle` (both the spotted and the talked-to branch) waits 30 frames, shrinks the light over 4, saves the candle as out, and turns the medium into `SPRITE_ITAKO_` (250). Falling into a pit re-runs the init and relights all four, as InitEcruteakGym does on the cartridge. Morty is home once `VAR_UNK_4079` is 1 (the Burned Tower); until then the old man walks the player out. Morty battles and gives the Fog Badge. The overworld harness takes `CMD_TRACE="pattern|pattern"` (log matching commands with their operands) and `FOG_OFF=1`. **Burned Tower to the Lake of Rage:** the beasts' scene in the Burned Tower B1F plays. `ScrCmd_523 obj, count, step, ampX, ampZ` rattles each beast in place (sub_0205BED8: offset amp × sin, the angle stepping each frame, `count` turns), and `CreateRoamer 0` / `1` release Raikou and Entei. HGSS's roamers run on Platinum's `Gen4Roamers` with HGSS's own tables (`src/world/HgssRoamers.lua`, generated from field_roamer.c: four slots, Raikou/Entei at 40 and Latias/Latios at 35, 41 locations and their adjacency). The rules are the same in both games: one long hop in 16, otherwise a neighbour other than the map just left, met one in two on its map. A long hop stays in the slot's region (Johto for the beasts, Kanto for the Latis), as RoamerLocationSetRandom does. Two fixes came out of it. `mapForHeader` prefers Generation 4 maps, and of two Gen 4 maps on one header prefers the plain id; another map def with a `header` field (`R_38`) had been answering for Route 38. The encounter check matches by header as well as by id. Chuck's gym: `CianwoodGymInit` / `CianwoodGymTurnWinch` keep the winch in the save, turn it once (answering 1, then 0) and wait out the camera pans; the waterfall props' animation (models 173–175) is not played, and the system flag the script sets only silences the waterfall's sound. Loan Pokémon (npc_trade.c): `GiveLoanMon` builds Shuckie (trade 6, Cianwood) or Kenya (trade 7, Route 35, with its mail) from the trade table at the script's level. `CheckReturnLoanMon` answers 0, 1 (not the loaned one), 2 (holding an item) or 4 (it would leave fewer than two able Pokémon), `ReturnLoanMon` removes it, and `KenyaCheckPartyOrMailbox` looks for Kenya's mail on the party (the mailbox is not built). Lance's departure from the Lake of Rage (`ScrCmd_775`) lifts him and Dragonite off the screen over 40 frames before the script hides them; the cartridge's flight path is not transcribed. Mahogany's `RunScript` / `CompareLocalToValue` / `ScrCmd_062` rows are bytes after a `goto` that no path reaches, so they are left alone. **The Rocket Hideout (Mahogany):** the B1F floor traps are wild battles (`RocketTrapBattle`: Koffing 21, Voltorb 23, Geodude 21); their flash (`ScrCmd_708`), the Persian statues' alarm (`709`) and the B2F doors' prop animations (`710`) are pictures and lower as no-ops. `MovePerson` is Platinum's `setobjecteventpos`, and `ScrCmd_530 n, on` writes sys_vars.c's magic value (or 0) into var 0x4036 + n. B2F's scene plays to `MultiBattle`, which is Platinum's tag battle in the same operand order: Lance (675) beside the player against Ariana (479) and a grunt (499). It is a long scene: the harness needs about 5,000 ticks at a press every 12. B2F's entry scene (Lance healing the party) runs while `VAR_UNK_40A9` is 0, and the doors at (30–31, 22) move aside once it reaches 3. `ScrCmd_436` (leaving the overworld for an app) is a no-op. **The Rocket disguise** (Radio Tower): `SetAvatarBits` is Platinum's `setplayerstate`. On HGSS bit 10 (`PLAYER_TRANSITION_ROCKET`) puts on the disguise and WALKING takes it off; the player is re-dressed as `rhero` / `rheroine` (sprites 258 / 259, members 95 / 96) through `Player:refreshForm`. `RocketCostumeFlagAction` / `Check` keep the save's costume flag. **Clair's gym** (T30GYM0101; field/gymmick/blackthorn.c, `src/world/HgssBlackthornGym.lua`): `BlackthornGymInit` puts the three platforms (build models 120, 121, 120 at height 48) back at (13, 75), (9, 58) rot 1 and (14, 32) on every entry. Each is a 3 × 6 floor with three buttons. Stepping on the centre one turns it a quarter about the player in 16 frames; if a wall is in the way it turns only as far as the extent tables allow and swings back, or not at all. Stepping on a side one carries the platform and the player its own width along the rotated axis, a tile every 2 frames, and bonks back to the start on anything that is not open magma. The geometry (walls, rotation probes, floor and buttons) is the C's, including its rotation check testing only `width − 1` right-wall points. Three hooks on the map def wire it in: `gen4PropFollow` runs the animation each frame; `gen4StepHook`, asked first in `onStepComplete`, is FieldSystem_ProcessStep's button test; `gen4CollisionHook`, asked first in Collision's tile verdict, is GymmickCheckCollision_Blackthorn (a platform's floor is walkable, bare magma 0x2C is not). Runtime props now carry `rotY` and a `ground` flag. A `ground` prop is never painted over the sprites; the platform's origin is its middle row, so a player standing on the rows north of it had been covered by the whole platform. In the harness, platform 1 slides five tiles west carrying the player (13 → 8). Its rotation at the start spot is refused by the walls, as it must be. The overworld harness takes `SHOW_HEIGHTS="x,z;…"` and `SHOW_PLAYER=1`. **The Hall of Fame:** the League's last room plays to the end. `HallOfFameAnim` is the machine's balls lighting one per Pokémon (prop 0x26), a no-op like Platinum's healing animation. `HOFCredits ` is CallTask_GameClear on Platinum's `g4_clear_game`: the same game-clear flag (0x964), the Hall of Fame screen and entry, the save with HGSS's own lines (msg_0040 entries 15 and 16, "Saving… Don't turn off the power." / "… saved the game."), and the credits. After Red on Mt. Silver (1) there is no Hall of Fame screen and no entry. HGSS skips the port's Mystery Gift deliveryman, which is Platinum's. The survey that found these rows missing now counts scene scripts (map callbacks and frame tables) as well as object and sign scripts. Across the whole game, what stays unlowered is side systems: the Game Corner, Union Room, Pokéathlon, Sinjoh Ruins, the Bug-Catching Contest, Vermilion's trash-can locks, the Deoxys meteorite on Route 3 and the Battle Frontier. **Kanto's gyms:** Lt. Surge's trash cans run as gymmick_init.c / vermilion.c (a random can, then a random neighbour of it; the can check answers 1/0, 2/3, 4; a gate's three stops run two tiles apart). With the Thunder Badge both gates stand open. In the harness the first can opened gate 0. Fuchsia's and Viridian's inits set the gymmick only: Janine's walls are map collision and Blue's spin tiles are forced movement; their flash and spin animations are not built. `SetBikeStateLock` is Platinum's `forcebicycling`. `ScrCmd_452` / `453` show and close the Latias/Latios picture during Steven's scene on Vermilion's pier (DrawPokemonPicFromSpecies: an 80 × 80 front picture framed at DS tile (9, 4)), and mark the species seen. The window is Gen 2's passive pokepic box (`src/ui/PicBox`), so the script keeps running under it. In the harness (`VAR_UNK_4130` = 1, Steven's flag 0x2FD clear) the HeartGold scene shows Latias and releases it onto Route 5. Blackthorn's move relearner and tutors lower onto Platinum's reminder and tutor screens. Not yet: the Pokégear, the Bug-Catching Contest and the Battle Frontier. `GetPhoneBookRematch` answers 0 while the phone is not built. Overall, 97.2% of HGSS's 181,648 script instructions lower. Most of the rest is the Battle Frontier, Pokéathlon, Union Room / Wi-Fi, the Game Corner and gym puzzles. + +Check: `python tools/run_lua_check.py tools/gen4_hgss_import_check.lua` +(88 checks). The check intercepts every write, so it never touches an +installed cache. Platinum re-extracts identically after these changes. + +## Black / White (generation 5, family `bw`) + +These games use their own extractor (`src/import/RomExtractorGen5.lua`). +pokeblack builds the cartridge but has not decompiled the data readers yet. +So every record layout in `Gen5Data.lua` is checked against values from the +ROM rather than taken on trust. + +| Stage | Status | +|---|---| +| text (encrypted, plus 9-bit compressed strings) | ported | +| species, learnsets, evolutions, forms | ported | +| moves, items (prices stored in tens), trainers and parties | ported | +| zones, matrices, map chunks | ported | `Gen5Maps` / `Gen5Field`. 427 zone headers (`/a/0/1/2`, 48 bytes each, every field measured against the archive it indexes), 255 matrices, and all 649 chunks (`WB`, `GC`, `NG`, `RD`). Every chunk's NSBMD packs through Gen 4's model pipeline. | +| ground textures | ported | each zone's area index names its set in `/a/0/1/4` (Striaton's 39 texture references are all in set 6) | +| walkability | ported | 8 bytes per tile: byte 4 is the behaviour, bit 0 of byte 6 is collision (read off a 13×15 room), translated to Gen 4 permission words | +| encounters | ported | `Gen5Encounters`: `/a/1/2/6`, 232 bytes per season (12 tables have all four). Grass, dark grass, rustling grass, surf, rippling surf, fishing and rippling fishing. Route 1 is Patrat and Lillipup from level 2. | +| type chart | ported | a 17×17 byte table found by signature (overlay 93 in Black) | +| font | ported | `Gen5Font`: the NFTR message font (5,064 glyphs, 12×15). Each 48-byte glyph slot opens with its own 3-byte width record (left, width, advance), then the 45-byte bitmap. Reading the record as bitmap was the stray dotted row on every line. | +| constants | ported | types, natures, growth curves (`/a/0/1/7`) and 8 badges | +| buildings | ported | Packs: `/a/2/2/9` (52 outdoor) and `/a/2/3/0` (64 indoor), each "AB" (building records by id, then the models in order), textured by `/a/1/7/6` / `/a/1/7/7`. Placements are x, y, z, a quarter-turn rotation (`Gen4Ground` now honours `rotY`) and the id. Pack = ⌊area ÷ 4⌋ outdoors and area − 210 indoors, from which pack holds every id a zone places; no header field gives it directly. 3,975 of 4,531 placements resolve; the rest are reported. | +| texture sets across zone borders | ported | a map's box takes in its neighbours' chunks, so it wears a merged set: its own first, then each neighbouring zone's | +| map events | ported | `Gen5Events`: `/a/1/2/5` (interactables, NPCs, warps, triggers at 20 / 36 / 20 / 22 bytes; 427 of 428 files add up exactly). Absolute matrix tiles are taken back to each map. | +| overworld sprites | ported | `Gen5Overworld`: the sprite table `/a/0/4/8` (799 ids) maps each to a member of `/a/0/4/9` (764 texture files, like Gen 4's `mmodel`). Frame order read from the sheets: three per direction for 12+ frames, a 7-frame layout for most NPCs. The player is sprite id 1 (Hilbert). | +| LZ11 | ported | `Gen5Lz` (BW's sprites and battle art use it) | +| script decode | ported | `Gen5ScriptOps` derives every command's operand width from the cartridge's own Thumb handlers (no decomp or public table exists). (1) Find the 624-slot handler table (overlay 10). (2) Identify the readers by shape: helpers that load, read and store back the script pointer at context offset 0x14. (3) A command's width is its handler's inline byte reads plus its callees' reads. (4) The table/opcode alignment (+15) is the one that makes the cartridge's own scripts decode: 3,233 of 3,383 entries end cleanly in Black, against 1,384 at the next best offset. Callee reads are counted one call deep: following deeper made 0x1C (run common script) 6 bytes instead of 2, which swallowed the `pop var` after every common-script call. Tail-jump stubs (`ldr r3, =routine; bx r3`) are followed to the routine. Three handlers are stubs, and 0x38's routine reads a var-or-value and an inline byte. Measured as 0 bytes, it made every script that used it decode askew afterwards (`38 0025 02, 32, 39` read as `38, 25 0232 0039`). With stubs followed, 3,349 of 3,383 entries end cleanly (99%), up from 3,233. Some stubs load a constant first and the routine second (`ldr r2, =constant; ldr r3, =routine; bx r3`: 0x4D, 0x4F, 0x61, 0x62); following those too gives 3,360. Before that, 0x4D read as 6 bytes and Striaton's TM gift decoded askew. | +| scripts running | partly | `Gen5ScriptLink` names the commands whose meaning the scripts make plain, in Platinum's words. That covers lock/release, face player, messages (0x3C/0x3D, with the message id at operand 3), wait for a press, close message, sound, flags, setting a variable, call/jump/return/end and wait-for-movement. It builds the same map-script pool HGSS uses, with 1,510 NPCs and signs linked. Story text also answers to `TEXT_B_`. **Conditionals:** the direct form (0x19 compare, then 0x1F with a condition byte) maps one to one. The stack form (push var 0x09 / push flag 0x10, push constant 0x08, then 0x11 and 0x1F 0xFF) is parsed as an expression. 0x11's operators come from its handler: 0–5 are < == > <= >= != (unsigned), 6 is OR and 7 is AND. **0x1F 0xFF jumps when the expression is false**: its handler pops the value and falls through on 1, as in a compiled `if (expression) { … }`. An earlier reading had this backwards, which inverted every stack conditional. That showed up when Z054's masseuse said she was exhausted on a fresh save; she checks `flag 0xAAE == 0` and sets 0xAAE herself after a massage. Each expression is rewritten as a chain of Platinum compare / check flag + `gotoif` that jumps when it fails. That gives 6,376 conditional jumps. About 5 expressions, which need a label in the middle of the chain, are left unlowered. **Common scripts:** 0x1C runs the shared script . The id table (46 bands, e.g. 2000–2099 → member 854 with text bank 158) is found in overlay 10 by shape and written to `gen5_version.scriptBands`. `Gen4ScriptBands.setFamily("bw", rows)` makes Platinum's `callcommonscript` resolve through it. The push/pop pairs that save 0x8000/0x8001 around these calls become copies into slot vars from 0x8100 and back (BW's own temporary vars stop at 0x8054). Also named from their handlers: 0x29 copy var, 0x2A set var to a var-or-value, 0x26 / 0x27 add / subtract, 0x03 wait frames. Named from their handlers plus the scripts around them: 0x6C remove object, 0x6D set position (Platinum's operand order), 0x4C buffer the player's name, 0x34 / 0x36 a second message box and its close, and 0x47 the yes/no box (0 is YES, as in Platinum). Several BW commands expand into a few Platinum rows (`rewriteCompound`). 0x107 is the party menu (Platinum's trade menu and selected slot; the ok var is 1 unless cancelled). 0x102 is "is that slot an Egg" (Platinum's species read gives an Egg as 0). 0xB3 is a brightness fade (Platinum's `fadescreen`) and 0xFD mode 1 raises friendship. Also 0xA9 / 0xAA (jingle and wait), 0xB4 (wait for fade) and 0xCB (random below a bound: the DS LCG at 0x020056EC). Z054's masseuse runs end to end: offer, YES/NO, party menu, the Egg and cancel checks, fade, jingle, a random 30 / 10 / 5 friendship and the matching line. `PARTY_DETAIL=1` on `tools/gen4_hgss_overworld_harness` prints each member's friendship afterwards. 0x43 / 0x44 are the sign-style box and its close; Platinum's message + waitabpress, then closemessage. Striaton's city sign reads "Striaton City / Three Stand Together…". 0xA8 waits for the last sound. 0x38 / 0x4A are two more message boxes, 0x39 closes them and 0x4B waits on them; 0x6E reads the player's facing. 0x85 starts a trainer battle (Platinum's `starttrainerbattle`; the trainer may be a var). 0x8D writes **1 for a win**, 0x8E returns to the field and 0x8C blacks out: the scripts run `if (var == 1) { 0x8E } else { 0x8C }`. An earlier reading had all three backwards, which the gym's shape cannot tell apart. The bedroom battle settles it: Bianca's battle may be lost, so 0x8E runs unconditionally, then `if (var == 1)` gives "you're gonna be an awesome Trainer" (bank 430 #37). 0x8D is Platinum's `checkwonbattle`, which also marks the trainer beaten. 0xD5 / 0xD6 / 0xE1 check, give and count badges. Talking to Striaton's gym leader (Z007) runs the challenge and starts the battle against Chili's Lillipup L12; the battle screen has no BW art yet. With `AUTO_BATTLE=win` (`tools/gen4_hgss_overworld_harness` settles a script's battle at once) the gym gives the Trio Badge, and the three leaders explain it. With `lose` it reaches the black-out row; as on Platinum that row is a no-op because the battle's own teardown blacks out. **Cutscene camera:** 0x143 names a point to look at, in fx32 world units (tile = value ÷ 65536, absolute matrix tiles). The gym's points at its three leaders, and Nacrene's (644.5, 583.5) is local (36.5, 7.5). It becomes Platinum's `addfreecamera`; 0x144 / 0x147 / 0x140 become `restorecamera`, and 0x13F / 0x141 / 0x142 / 0x145 are no-ops. BW's own pitch, distance and frame count are not carried, so a cutscene shows under whatever CAM TILT the player chose. The view jumps to the point rather than panning there. 91% of 81,173 instructions are now named (the shared files are in the pool). Talking to Z005's NPCs runs common script 10110 and prints its "Hello!" from bank 424. Talking to Striaton's NPCs runs their scripts and prints their lines. **Movement:** 0x64 is `applymovement` (object, u32 offset from the byte after the 8-byte instruction). All 4,129 lists end at 0xFE in Gen 4's {u16 action, u16 count} form. The object ids are Gen 4's: 0xFF is the player (1,060 sites); otherwise it is the NPC's local id. So `Gen4Movement` decodes them unchanged and Platinum's `g4_move` runs them. 98.3% of steps use actions Gen 4 already numbers the same way. The other 25 ids (all above 0x99, 177 steps) are skipped, not guessed. In Z033's queue, the NPC turns to the player, talks, then turns back. NPC visibility flags share the scripts' flag names. | +| tile heights | ported | the same 8-byte tile records: bytes 0–1 are 0 on a flat tile, and then bytes 2–3 are its height in world units (signed). The gym's dais is 14 on exactly its tiles, Striaton's ledges 24, Nacrene's streets 120. They are written as each map's `gen4HeightEdits`, the per-tile override `Gen4Ground:heightsAt` reads before any BDHC, so people stand on raised floors in 3D views. Sloped tiles (non-zero bytes 0–1, 3%) are not decoded yet and take their neighbours' height. | +| building placement | fixed | a placement's z is stored negated (north positive). Read as it stood, Nuvema's living-room furniture stood half a chunk south of its floor (floor z −250..−72, pieces 120..232) and Striaton's gym a block from its door; negated, both sit where the walls and doors say. Rotation is used as stored. | +| character transparency | fixed | the field draws character billboards with colour 0 clear whatever the texture's bit 29 says; Cheren's sheet leaves the bit off and came out on a green square. `Gen4Models.sheet(..., { color0Transparent = true })` for BW's palette formats. | +| new-game start | ported | the new-game location setter (ARM9 0x2014204) is called with zone 391, the player's room, and takes the spot from the zone header's +0x24 / +0x2C (now parsed as `spawnX` / `spawnZ`): (5, 6), facing 2. The first blackout point is the living room below (zone 390). BW's intro is not ported, so a new game opens in the room. | +| the opening (Nuvema → Route 1) | plays | Runs in the harness from the bedroom to Juniper's demonstration. **Gift box** (zone 391, member 782): 0x153 is the starter app (`src/ui/Gen5StarterSelect`: Juniper's letter, the three choices with bank 430's type lines, YES/NO). The script turns 0/1/2 into Snivy/Tepig/Oshawott and gives it with 0x10C (Platinum's `givepokemon`). 0x057 / 0x053 / 0x054 buffer a species name / a party Pokémon's species / its nickname (byte slot first). Bianca's and Cheren's battles follow; 0x101 asks whether HP is full (or an Egg) and 0x104 heals the party. **Shared files:** 0x1C runs a common script in its own context, so a shared file's `end` is lowered as a return; otherwise the give-item script's `end` ended Mom's scene. **The bag:** 0xB5 add, 0xB6 take, 0xB7 fits, 0xB8 has, 0xB9 count (Platinum's shapes); 0xBB is the pocket, bits 7–10 of the item record's u16 at 0x08, kept as `bwPocket` (0 Items, 1 Medicine, 2 TMs, 3 Berries, 4 Key Items); 0x052 names it from system bank 55. Mom gives the Xtransceiver, then the Town Map outside. **The lab** (member 792): 0x105 opens the naming screen (Platinum's `openpokemonnamingscreen`, title from system bank 81), 0x110 answers whether it now has a nickname; the Pokédex is `setflag 0x962`. **Route 1** (member 634): 0x17D is Juniper's catching demonstration. Its routine (0x0216E95C) builds a level 7 Minccino (Pound, Growl) against a wild level 2 Patrat (Tackle, Leer). The name on her side is reconstructed (no name bank holds it). Juniper then gives five Poké Balls. Still unnamed and stepped over: 0x09E, 0x0E7, 0x06B, 0x098, 0x25F, 0x048, 0x069, 0x24E–0x250 (cosmetic as far as the scripts show: nothing reads them back). | +| Accumula → Route 2 → Striaton | plays | **New-game flags:** a new game runs one shared script of nothing but `setflag`s to hide the actors whose stories haven't begun (Platinum's 112). Black's is a subroutine of band 9600 (member 866): 131 flags and `0x4116 = 1`. `Gen5ScriptLink.newGame` finds it by that shape, and the constants stage writes it as `gen4NewGame`. Without it, Accumula's Plasma crowd stood in the square from the first frame. **Triggers:** the value is the record's *second* halfword (the +6 one is 0 everywhere). Accumula's speech fires at 0x407A == 2, after Juniper's tour; Route 1's exit fires at 0x407C == 1. Coord triggers are now linked into the pool (they weren't before). **Shared object scripts:** an object script id past the zone's own (the nurse 2100, the PC 2101, 10100+) is filed under `shared.objects` as `{band, entry}`, as on Gen 4. **Counters:** BW's counter behaviour is 0xD4 (`Map:isCounterCell`). **Commands:** 0x0BE is a warp (Juniper into the Pokémon Center). The nurse uses 0x0CE (time of day), 0x0D0 / 0x0D4 (today / the DS-profile birthday; equal triggers "Is today your birthday?", and `save.player.birthday` supplies one) and 0x100 (Pokérus). 0x03A / 0x03B are the floating windows (the crowd's murmurs; shown in the ordinary box). 0x0E3 gives the Running Shoes (Mom's Xtransceiver call on Route 2). 0x068 is the player's matrix position (Striaton's guard: `if (x == 750)`). 0x0E0 is the version id (21 Black, 20 White). In the harness: Juniper's welcome and tour, the nurse across the counter, Ghetsis's speech and N's battle, Mom's call, Bianca's Route 2 battle, Striaton's no-badge guard. Unread: 0x0CC (the calling script's own id, handed to the item-ball script), 0x018 (compare a local register), 0x021 / 0x022 (a save halfword / byte). | +| Striaton → Fennel → Dreamyard | plays | **The gym's curtains** are invisible blockers. An NPC record's halfwords at +14/+16 are a pair whose meaning depends on the object. Sprite 185 (no picture, 54 objects) is a blocker that many tiles wide and tall (the gym's curtains are 7 × 1). A trainer's +14 is its sight range, and on anyone else the pair is the wander range. Blockers import as `buried` (hidden but solid) with `footprintW/H`, which `Collision.entityBlocks` honours. The right doorway's script walks the curtain aside; the wrong ones only rustle (0x187/0x189 sounds). **Decode fix:** some tail-jump stubs load a constant first and the routine second (0x4D, 0x4F, 0x61, 0x62). Read as 6 bytes, 0x4D swallowed the gym's `setvar 0x4088 = 1` after the TM, so Fennel never appeared. **Machines:** the ARM9's u16 table (TM01–92, HM01–06, TM93–95) is stamped on items 328–425 and 618–620 as `machine.move` (BW's ids aren't one run, so ItemEffects' join can't place them). 0x04D / 0x04F / 0x050 buffer an item name, a machine name and a machine's move. 0x056 buffers a type name (from the constants' own names). 0x0C2 is a warp like 0x0BE (Fennel's "follow me"). **GC chunks** (47 of them, the Dreamyard's included) have four sections: model, permissions, a second layer, buildings. Read as WB, every tile fell back to blocked. **Cut:** 0x116 is `findpartyslotwithmove` (6 = none). BW's last-talked var is 0x8011 (the overworld sets it on talk, as 0x800D on Gen 4), so the tree's shared script removes the right object. In the harness: the curtains, Chili, the Trio Badge and TM83, Fennel's HM01, the Cut tree, the Panpour gift and the Plasma grunts' battles, to the scene's end (0x4094 = 4). `PARTYMOVES=15,33` on the overworld harness sets the lead's moves. Not read yet: RD chunks' 24-byte tiles (8 chunks), GC's second layer. | +| trainers, Route 3, Wellspring Cave, Nacrene | plays | **Trainers:** an object whose script is in the 3000/5000 bands is a trainer (id = script − 3000 or − 5000), as on Gen 4; Striaton's 3009/3010 are Waiter Maxwell and Waitress Tia. Its sight range is the record's +14 halfword, and `sightWays` is Gen 4's rule on the trainer type. They now spot the player. **Trainer lines:** `/a/0/9/0` (1,762 {trainer, type} records, one per line of system bank 189) and `/a/0/9/1` (one offset per trainer, 616) are Platinum's index; the trainers stage writes `gen4_trainer_messages`. All 1,762 are reached with no duplicates, and types 0/1/2 are the challenge, the defeat and the after-battle line. **The shared trainer script** (band 3000, member 863): 0x084 trainer id, 0x097 beaten?, 0x095 / 0x096 set / clear the beaten flag (an event flag at a base plus the id), 0x092 after-battle kind (1 = heals you, from the record's byte 0x10; 2 = prize item, 0x12), 0x08A battle type, 0x103 party count, 0x088 / 0x087 message types and the line, 0x08B the encounter music, 0x066 / 0x073 the object's movement type. 0x086 is a tag battle (Wellspring Cave: Cheren with you against two grunts). 0x025 puts a flag's state into a var, and 0x006 is a no-op. In the harness: Route 3's twins (a double battle, refused with one Pokémon as on the cartridge), Cheren, the stolen-Pokémon chase, both Wellspring grunt battles and the reward, Nacrene's arrival and Lenora's museum welcome. `PARTY=495,515` on the overworld harness builds a party of several. Not modelled: triple/rotation battle types (0x08A answers only double), and some ops that look cosmetic (0x15A, 0x1F3–0x1F7 in one zone each, 0x1B1, 0x06B, 0x0AB/0x0AC emotes, 0x09E). | +| walking between places | ported | **Doors and stairs:** in BW a door's or staircase's cell is a plain solid tile (byte 4 = 0x01, like the wall beside it), so the warp event on it is all that makes it an entrance. Pressing into a solid cell carrying a warp takes it, and pressing off the warp underfoot (an exit mat, a map edge) takes that, except on the cell the player just arrived on, until they step off it. Arriving facing south walks one step out of the doorway, as on Gen 4. **Warp records:** +4 is the kind (1 mat, 2 door, 3/4 stairs and passages) and +14/+16 a width × height. Extra cells are appended after the real warps, so `destWarp` keeps indexing. `destWarp` is stored 1-based, as the engine reads `warps[destWarp]`. **Seams:** zones cut from one matrix get Gen 4's edge `connections` (`RomExtractorGen4.linkRegions`). A BW zone is a set of chunks, not a rectangle (Route 2's box wraps round Accumula's single chunk), so after each step the matrix's zone table (`headers` on `gen4_map_matrices`) names whose chunk the player is in, and the player is handed over seamlessly. **Not Sinnoh:** Platinum's hidden-path collision patches key on the overworld matrix id, and BW's Unova overworld is matrix 0 too; they walled off Route 1's road into Accumula, and are now skipped on BW maps. Lenora's gym plays through: the quiz books, the stairs to the basement, Lenora (trainer 21), the Basic Badge and TM67. | +| Pinwheel Forest; rails (not yet) | plays to Castelia's edge | Pinwheel Forest (Z154/Z155) plays: Burgh's split, its trainers, the three-grunt chase (309, 296, 297), the Dragon Skull (579) recovered and returned to Lenora. Triggers have a **second shape**: when +8 is 1, an elevation sits between the position and the extent (x, y, signed z, width, height). There are nine such triggers (Castelia's, Route 4's, Victory Road's); read as the first shape, they were 65,531 tiles wide. **Rails are the next blocker.** Castelia (matrix 132) and Skyarrow Bridge (133) are built from "NG" chunks with no permission grid. Their warps carry +6 = 1 and rail coordinates (a line, a distance along it, a signed offset across) instead of tiles, so they need BW's rail system: its data (not yet located; ruled out: the event files, `/a/2/2/0`, `/a/1/5/6`, the 668-member move/learnset archives), rail movement, and rail-coordinate placement. **What the code says about rails (Black):** the rail zones are a fixed u16 list in the ARM9 at 0x0209E010: 28 (Castelia's centre), 249 (Skyarrow Bridge), 214, 209, 211, 66, 241–244, 36. `0x02013C70` asks whether a zone is on it. The new-game setter `0x02014204` then stores the zone header's spawn fields (+0x24/+0x28/+0x2C, Castelia's 3, 8, 1) as a rail location in the Location struct (+0x0C = 1, then halfwords +0x10/+0x14/+0x16) instead of a position. The player object carries a grid mover (+8) and a rail mover (+0x0C). Overlay 21's event check at 0x0218A340 branches into the rail code around 0x0219F01C–0x021A3960. Castelia's streets (Z030–Z034, Z037, Z041) are ordinary grid maps; only Z028 and Z036 and the bridge are rails, but every road into Castelia passes through Z028. Not the rail file (checked): `/a/0/8/1`, `/a/1/5/8`, `/a/0/9/8`, `/a/1/7/1`, `/a/2/3/1` (bitmaps), and the archives overlay 21 opens through the table at 0x021A2978 (graphics). **The camera, found on the way:** zone header halfword +0x1C bits 9–15 (`0x02013BBC`) is passed to the camera set-up at `0x0218E018`, which opens archives 60 (`/a/0/6/0`, one 1,672-byte table) and 108 (`/a/1/0/8`, 282 records of 28 bytes). Every Pokémon Center interior shares index 3. That is BW's camera table, now read: 44-byte records (38 in Black) at index × 44, giving distance (+0x00, world units), pitch (+0x04, not negated: 0x25D8 = 53.2°), half field of view (+0x12, 0x0E38 = 20°) and the look-at offset (+0x20 VecFx32). The default's distance and field of view give 1.1 pixels per unit, the scale Platinum's rows give. Each map def carries its record as `gen5Camera`, which `Gen4Camera.forMap` prefers; the player's CAM TILT choice still applies on top. Near and far planes are not decoded (far is taken from the u16 at +0x1A × 16, near is 16). | +| rail data | extracted (not walked yet) | **Found:** zone → a packed u16 table in the ARM9 right after the rail-zone list (`0x0209E026` in Black: low 11 bits the zone, high 5 an index 0–16) → `/a/1/0/9[index]` = {u16 rail file, u16 camera area} (`0x021BB278`) → `/a/0/7/9[rail file]` (`0x0219EF7C`). A rail file is a header of u32 offsets and four record sections, named in the cartridge's own bytes: **points** (`RE_POINT_n`, 112 bytes: four line slots, four direction keys, four widths, fx32 x/y/z), **lines** (`RE_LINE_n`, 72 bytes: point a, point b, then shape/camera references), **line shapes** (36 bytes; types 3, 5, 6 seen) and **curves** (36 bytes; type 1 = a circle about an fx32 centre, e.g. Castelia's round plaza about (767, 15, 765)). Castelia's centre (zone 28) is file 5: 8 points and 6 lines. Skyarrow Bridge (249) is file 11: 21 points and 20 lines. NG chunk models use `posScale` 512, eight times a WB chunk's 64, so an NG chunk spans about 4,096 units and the points fit inside it. The field stage writes `gen5_rails` (by file) and `gen5Rail = {file, cameraArea}` on each rail zone's def. **Not yet:** drawing NG chunks at their real size, movement along lines (line, distance along, offset across), and placing warps/NPCs/triggers by rail position. The camera areas (`/a/1/5/8`, 72-byte records) are also unread. | +| battle art, abilities | not yet | | + +The Black/White field renders through `Gen4Ground`, with Platinum's camera +code and controls (the default camera; Gen 5's own camera table is not +identified yet). Every CAM TILT mode works on HGSS and BW: cartridge, the fixed angles, third person and first person. The options row is offered for every Gen 4-shaped cache. `CAM_TILT=cartridge|78|72|66|60|53|47|41|third|first` on `tools/gen4_hgss_overworld_harness` picks one before the first frame. Third person now stops in front of walls, for every Gen 4 cartridge (`Gen4Ground:clearCameraLine`). The line from the player's chest to the eye is marched a few units at a time. The first blocked tile (bit 15 of the permission word) that is not water and is crossed less than 32 units above its ground brings the eye in to just before it. The eye moves along the same line, so the player stays centred and the pitch is unchanged, and it never comes nearer than 12 units. Tested with Striaton's Pokémon Center and a Twinleaf house behind the player; open ground, HGSS and Platinum's default views are unchanged, and `gen4_tilt_height_check` still passes 90 / 90. Striaton City renders with its buildings: the Pokémon Center and the Gym. Route 1 renders its water, cliffs and trees. + +Platinum's hidden-path chunk edits (`Gen4HiddenPaths`) are now limited to +Platinum. They are written against Platinum's matrix 0 and were stamping Sinnoh +chunks into Unova's (and Johto's) matrix 0. + +`OUT_DIR=` makes `tools/gen4_hgss_overworld_harness` write its PNGs +there, not to LÖVE's save folder on the system drive. + +Striaton City now draws with Hilbert and its NPCs among the buildings. + +Check: `python tools/run_lua_check.py tools/gen5_import_check.lua` (88 checks). + +``` +CACHE_ROOT=G:/Gen2Recomped VERSION=black ROM="./Pokemon - Black Version (USA, Europe) (NDSi Enhanced).nds" \ + love tools/gen4_hgss_import_harness +INSTALL=G:/Gen2Recomped/black VERSION=black MAP=Z006 X=48 Y=16 love tools/gen4_hgss_overworld_harness +``` + + +## Mods + +`ModGens` declares generations 1 to 5. `Manifest` no longer drops a mod's +Gen 4 or Gen 5 claim. diff --git a/mods/DRAMATIC_SHAPE/data/firered/gen3_palings.lua b/mods/DRAMATIC_SHAPE/data/firered/gen3_palings.lua index afb0645d1..ddfb720b9 100644 --- a/mods/DRAMATIC_SHAPE/data/firered/gen3_palings.lua +++ b/mods/DRAMATIC_SHAPE/data/firered/gen3_palings.lua @@ -124,6 +124,14 @@ return { -- east-west, running through the cell [231] = { posts = { { 1, 10 }, { 9, 10 } }, rails = { { "x", 10, 0, 16 } } }, + -- ...and the same run with the crown of the tree south of it + -- drawn over its lower half (214 west, 215 east): the cycling + -- road's fences. Unlisted, they were raised as rock and stood + -- as green boxes with the fence painted on top. + [214] = { posts = { { 1, 10 }, { 9, 10 } }, + rails = { { "x", 10, 0, 16 } } }, + [215] = { posts = { { 1, 10 }, { 9, 10 } }, + rails = { { "x", 10, 0, 16 } } }, -- ...and turning NORTH out of the cell: 252 on the west post -- (its rows 0..1 carry that post's body at columns 2..5), 253 on @@ -141,6 +149,17 @@ return { rails = { { "x", 10, 0, 16 }, { "z", 1, 10, 16 } } }, [237] = { posts = { { 1, 10 }, { 9, 10 } }, rails = { { "x", 10, 0, 16 }, { "z", 9, 10, 16 } } }, + -- ...and RUNNING NORTH-SOUTH, which Kanto draws as a thin rail + -- bar down one side of the cell: 244 on the west lane (its bar + -- is columns 2..5), 245 on the east (columns 10..13) -- the same + -- two lanes 252/253 and 236/237 turn into, so a straight line + -- meets its corner without a jog. Reported: "in cerulean some + -- of the fences are flat" -- every one of them was one of these + -- two, meshed as grass with the fence painted on. + [244] = { posts = { { 1, 2 }, { 1, 10 } }, + rails = { { "z", 1, 0, 16 } } }, + [245] = { posts = { { 9, 2 }, { 9, 10 } }, + rails = { { "z", 9, 0, 16 } } }, }, }, }, @@ -169,6 +188,15 @@ return { -- the pair (tools/gen3_find_under.py), and each came back with exactly ONE -- candidate. overhead = { + -- ROUTE 23's BADGE-GATE STATUES (the road to Victory Road): a Pokemon + -- statue drawn wholly above the player in the wall row (663), standing + -- on the plaque pedestal one cell south (751 / 671). Lifted off the wall + -- and stood on the pedestal; the wall behind it wears the gate's panel. + ["TILESET_02D4A94_02D4B84"] = { + figures = { + { name = "badge gate statue", meta = 663, under = 672, south = 1 }, + }, + }, -- CELADON DEPARTMENT STORE: the planters flanking the shop floor. 702 -- and 703 each carry 138 px of above-player frond over the two floor -- shades, and 710/711 (pinned `cylinder` in gen3_shapes.lua) are the diff --git a/mods/DRAMATIC_SHAPE/data/gen3_shapes.lua b/mods/DRAMATIC_SHAPE/data/gen3_shapes.lua index cea834642..a33531c6c 100644 --- a/mods/DRAMATIC_SHAPE/data/gen3_shapes.lua +++ b/mods/DRAMATIC_SHAPE/data/gen3_shapes.lua @@ -598,7 +598,9 @@ return { -- and hanging skirt -- are stood up as floating decks on the drawn -- poles (Structures.buildGen3Stalls). gTileset_Slateport = { - stall_awning = { 517, 525, 526, 527, 551, 565, 559, 760, 761, 762 }, + -- 549/519 over 557/567 are the narrow stalls' own edge columns + stall_awning = { 517, 525, 526, 527, 551, 565, 559, 760, 761, 762, + 549, 519, 557, 567 }, }, -- EVERY POKEMON CENTER'S STAIRCASE (34 maps share this tileset). @@ -625,14 +627,6 @@ return { }, }, -gTileset_Sootopolis = { - behaviour = { [0x0C] = "cliff" }, -- MB_MOUNTAIN_TOP - -- Every roof in the crater is drawn as a dark CONE over an octagonal - -- body. Built as sheds they lean into squat lozenges; this says the - -- pointed style exists here, and the octagon trim picks which - -- buildings actually taper (Structures: tent test). - tent_roof = true, - }, -- LAVARIDGE: Mt Chimney, Jagged Pass and the Fiery Path. The volcano's -- flank is drawn as one continuous scree -- the same rock motif the @@ -865,17 +859,62 @@ gTileset_Sootopolis = { -- footprints have swallowed what stands above and beside them; that is a -- fault in the footprint, not in the split, and banding them would only -- paint a roof over the wrong extent. + -- RUSTBORO'S GREY BLOCKS. Every house and both apartment blocks are one + -- drawing: a flat grey roof three rows deep (544..563, or 548..558 on + -- the narrower blocks) over a facade of window rows (568..579, or + -- 532..535/578) and a door course (540..543). Emerald does not cap a + -- building at its wall art, so Flat2 (3..9, 43..51) -- three roof rows + -- over six of windows -- stood its whole nine rows, a 144px slab that + -- filled the view of the street beside it, and Flat1 96px over 48px of + -- facade. Classed, each stands exactly the windows it draws. gTileset_Rustboro = { -- RustboroCity: Centre (15..18, 36..38), Mart (15..18, 43..45). roof_bands = { [598] = 5, [606] = 5, [607] = 5, [599] = 5, [615] = 5 }, + building_art = { + [544] = "roof", [545] = "roof", [546] = "roof", [547] = "roof", + [552] = "roof", [553] = "roof", [554] = "roof", [555] = "roof", + [560] = "roof", [561] = "roof", [563] = "roof", + [548] = "roof", [549] = "roof", [550] = "roof", + [566] = "roof", [574] = "roof", [567] = "roof", + [556] = "roof", [557] = "roof", [558] = "roof", + [568] = "wall", [569] = "wall", [570] = "wall", [571] = "wall", + [551] = "wall", [576] = "wall", [577] = "wall", [559] = "wall", + [579] = "wall", + [532] = "wall", [533] = "wall", [534] = "wall", [535] = "wall", + [578] = "wall", [540] = "wall", [541] = "wall", [542] = "wall", + [543] = "wall", + }, }, gTileset_Lilycove = { -- LilycoveCity: Centre (23..26, 11..14). Nothing is drawn above it, -- so the keyed course is the footprint's own first row. roof_bands = { [619] = 5, [620] = 5, [621] = 5, [622] = 5 }, }, + -- ONE ENTRY PER TILESET. Sootopolis was named twice in this table, and a + -- Lua constructor keeps the LAST key: the crater wall and the tent roofs + -- above were silently dropped for the roof bands below, so every cone + -- came out a squat lozenge. Merged here. gTileset_Sootopolis = { + behaviour = { [0x0C] = "cliff" }, -- MB_MOUNTAIN_TOP + -- Every roof in the crater is drawn as a dark CONE over an octagonal + -- body. Built as sheds they lean into squat lozenges; this says the + -- pointed style exists here, and the octagon trim picks which + -- buildings actually taper (Structures: tent test). + tent_roof = true, + -- THE TENTS' OWN ART. Each house is a dark cone over an octagonal + -- body, (8..10, 13..17) the first of nine: the tip row 520/528/536, + -- the cone 521..523 over 547/532/548 (the outer two drawn above the + -- player), the door course 555/540/556. The door founds only the + -- three rows above it, so the tip row fell to the crater rock behind + -- and the cone was cut off at its shoulders. Named, the footprint + -- grows up through the whole drawing. + building_art = { + [520] = "roof", [528] = "roof", [536] = "roof", + [521] = "roof", [522] = "roof", [523] = "roof", + [547] = "roof", [532] = "roof", [548] = "roof", + [555] = "wall", [540] = "wall", [556] = "wall", + }, -- SootopolisCity: Centre (42..45, 28..31), Mart (16..19, 26..29). -- -- 5 like the rest, for uniformity -- but NEITHER of these two looks @@ -974,7 +1013,42 @@ gTileset_Sootopolis = { -- carry above. What is NOT affected: the ledges you hop (stated by -- behaviour), and the mountains themselves, which are blocked scenery -- built by `climbGen3Masses` and never were terraces. + -- THE SEVII HARBOURS (FireRed, islands one to three): drawn on the sea + -- map below each island, whose door warp is on the island itself. + frlg_gTileset_SeviiIslands123 = { + doorless_art = { + [703] = true, [709] = true, [711] = true, [717] = true, [719] = true, [725] = true, [755] = true, [756] = true, [757] = true, [758] = true, [759] = true, [760] = true, [761] = true, [762] = true, [763] = true, [764] = true, [765] = true, [766] = true, [767] = true, [768] = true, [769] = true, [770] = true, + [752] = true, [753] = true, [754] = true, [779] = true, [780] = true, [781] = true, [782] = true, [783] = true, [727] = true, [743] = true, [735] = true, [751] = true, [656] = true, [676] = true, [664] = true, [684] = true, + }, + }, + + -- THE SEVII HARBOURS on islands four to seven: drawn on the island's + -- own map with the door on the island side, the building south of it + -- over the water. Named so the any-face door pass founds them. + frlg_gTileset_SeviiIslands45 = { + building_art = { + [646] = "wall", [647] = "wall", [654] = "wall", [655] = "wall", [662] = "wall", [663] = "wall", [670] = "wall", [671] = "wall", [710] = "wall", [711] = "wall", [715] = "wall", [716] = "wall", [717] = "wall", [718] = "wall", [719] = "wall", [723] = "wall", [724] = "wall", [725] = "wall", [726] = "wall", [727] = "wall", [731] = "wall", [732] = "wall", [733] = "wall", [734] = "wall", [735] = "wall", [739] = "wall", [740] = "wall", [741] = "wall", [742] = "wall", [743] = "wall", [747] = "wall", [748] = "wall", [749] = "wall", [750] = "wall", [751] = "wall", [755] = "wall", [756] = "wall", [757] = "wall", [758] = "wall", [759] = "wall", + }, + }, + frlg_gTileset_SeviiIslands67 = { + building_art = { + [642] = "wall", [646] = "wall", [648] = "wall", [654] = "wall", [656] = "wall", [662] = "wall", [664] = "wall", [670] = "wall", [744] = "wall", [745] = "wall", [746] = "wall", [747] = "wall", [748] = "wall", [749] = "wall", [750] = "wall", [751] = "wall", [752] = "wall", [753] = "wall", [754] = "wall", [760] = "wall", [761] = "wall", [762] = "wall", [763] = "wall", [764] = "wall", [765] = "wall", [766] = "wall", [767] = "wall", + }, + }, + frlg_gTileset_General = { + -- KANTO'S ROUTE GATE HOUSES, every one of them drawn from the same + -- primary ids: Saffron's four, Route 7's, Route 16's and Route 18's + -- cycling-road gates. The art repeats across the region, so it read + -- as landscape and each gate came out as a crown of rock spires. + building_art = { + [68] = "roof", [69] = "roof", [70] = "roof", [76] = "roof", [77] = "roof", [78] = "roof", [387] = "roof", [388] = "roof", [389] = "roof", [437] = "roof", [438] = "roof", [439] = "roof", + [92] = "wall", [93] = "wall", [94] = "wall", [100] = "wall", [101] = "wall", [102] = "wall", [392] = "wall", [393] = "wall", [394] = "wall", [400] = "wall", [401] = "wall", [402] = "wall", + -- the Underground Path entrances (Routes 5..8): an eave course + -- (84..86, 103) between the gate roof and a one-row brick front whose + -- door is 61. Unnamed, the hut stood as a steep narrow wedge. + [84] = "eave", [85] = "eave", [86] = "eave", [103] = "eave", [61] = "wall", + }, elevation_height = true, drawn_terraces = false, role_terraces = false, @@ -2554,8 +2628,417 @@ gTileset_Sootopolis = { }, + -- CLASS PINS FOR ONE MAP, after the tileset's own (`metatiles` above). + map_metatiles = { + Route23 = { + -- the statue bodies (671/751, the plaque) under the heads lifted by + -- the badge-gate figure in gen3_palings: each stands where it is + -- drawn, from the path up, instead of a sign card at one end and a + -- cliff (or a column of the wall) at the other + [671] = "post", [751] = "post", + }, + }, + maps = { + -- LAVENDER TOWN'S POKEMON TOWER (FireRed), (15..21, 0..6). + -- + -- Every storey is blocked, solid and the tower's own art, but the + -- window band repeats across the face, so the art read it as rock: the + -- tower came out as a two-course box with a lumpy cliff of its own + -- facade standing behind it. These are the tower's ids in the + -- Lavender pair, all of them wall -- the drawing has no pitched roof. + -- SAFFRON CITY'S DOJO (FireRed). The gate houses' art is the shared + -- primary's and is named on frlg_gTileset_General below. + -- + -- Four gate houses -- one on each road out -- share their art, so it + -- reads as tiling landscape and none of them was ever founded as a + -- building: each came out as rock, a jagged crown of art-height + -- columns. The Dojo, beside the Gym, likewise. Their roofs are the + -- grey gate roof (68..78 over 387..439) and their walls the window band + -- and doorway (92..102, 392..402); the Dojo is its own block of ids. + SaffronCity = { + building_art = { + -- the Dojo: its top row is the tree line behind it drawn over a + -- thin tan roof (eave), then its eave band and brick front + [816] = "eave", [817] = "eave", [799] = "eave", [818] = "eave", + [824] = "wall", [825] = "wall", [826] = "wall", [832] = "wall", [833] = "wall", [834] = "wall", [821] = "wall", [822] = "wall", [823] = "wall", [811] = "wall", [812] = "wall", [827] = "wall", [831] = "wall", [820] = "wall", + }, + }, + + -- CELADON CITY'S APARTMENT BLOCKS (FireRed), (18..45, 4..11). + -- + -- Drawn front-on like the Condominiums they stand beside -- storeys of + -- windows, no roof seen from above -- and most of them have no door, so + -- nothing founded them: the west block came out as a stepped wedding + -- cake and the east ones as low boxes with jagged tops. `elevation` + -- names a cell as building art drawn as a front view; a doorless mass + -- of it six or more cells tall is stood up whole, a course per tile + -- row. A key of its own rather than `building_art`, which would also + -- re-class the same ids on the low blocks of the south side. + CeladonCity = { + -- ...and the low blocks beside the Pokemon Centre, (34..45, 8..11): + -- the same art four cells tall, a flat roof over two storeys, with + -- no door -- stood as rock steps until named here. + doorless_art = { + [736] = true, [737] = true, [738] = true, [739] = true, [740] = true, [744] = true, [745] = true, [747] = true, [748] = true, [752] = true, [753] = true, [755] = true, [756] = true, [760] = true, [761] = true, [763] = true, [764] = true, + }, + elevation_art = { + [729] = true, [730] = true, [731] = true, [732] = true, [733] = true, [734] = true, [735] = true, [736] = true, [737] = true, [738] = true, [739] = true, [740] = true, [741] = true, [742] = true, [743] = true, [744] = true, [745] = true, [746] = true, [747] = true, [748] = true, [749] = true, [750] = true, [751] = true, [752] = true, [753] = true, [754] = true, [755] = true, [756] = true, [757] = true, [758] = true, [759] = true, [760] = true, [761] = true, [762] = true, [763] = true, [764] = true, [765] = true, [766] = true, [767] = true, [768] = true, [769] = true, [770] = true, [771] = true, [772] = true, [773] = true, [774] = true, [775] = true, [776] = true, [777] = true, [778] = true, [779] = true, [780] = true, [781] = true, [782] = true, [783] = true, [784] = true, [785] = true, [786] = true, [787] = true, [788] = true, [789] = true, [790] = true, [791] = true, [792] = true, [793] = true, [794] = true, [795] = true, [796] = true, [797] = true, [798] = true, [799] = true, + }, + }, + + -- CERULEAN CITY'S NORTH-EAST HOUSE (FireRed), (38..43, 7..10): a blue + -- roofed house no warp leads into, so nothing founded it and it came + -- out as a jagged block of rock beside the trees. + CeruleanCity = { + doorless_art = { + [675] = true, [676] = true, [677] = true, [678] = true, [680] = true, [681] = true, [682] = true, [688] = true, [689] = true, [690] = true, [694] = true, [695] = true, [696] = true, [698] = true, [702] = true, [703] = true, + }, + }, + + -- ROUTE 23's south edge is the BACK of the Route 22 gate, whose door is + -- in its north face here; named so the any-face door pass founds it. + Route23 = { + building_art = { + [752] = "wall", [753] = "wall", [754] = "wall", + [760] = "wall", [761] = "wall", [762] = "wall", + -- THE BADGE-GATE WALLS: a grey stone top (652..654) over a front of + -- orange pillar band and tan panels (660..673, the statue's cell + -- 663 among them). No door, so nothing founded them and they stood + -- as rock at 16 to 64px column by column -- a ragged slope beside + -- the statues where the art draws an upright wall. + [652] = "roof", [653] = "roof", [654] = "roof", + [688] = "roof", [692] = "roof", [696] = "roof", [700] = "roof", + [660] = "wall", [664] = "wall", [665] = "wall", [668] = "wall", [672] = "wall", [673] = "wall", [663] = "wall", + }, + doorless_art = { + [652] = true, [653] = true, [654] = true, + [688] = true, [692] = true, [696] = true, [700] = true, [660] = true, [664] = true, [665] = true, [668] = true, [672] = true, [673] = true, [663] = true, + }, + -- THE ARCH BETWEEN THE WALLS: its posts (688/692 over 696/700) are the + -- walls' end columns, named above; its beam is drawn above the player + -- across two walkable rows and is raised to the walls' height by + -- Structures.buildGen3Arches. Each beam cell with its clean ground. + arch_span = { + [689] = 17, [690] = 16, [691] = 17, + [697] = 9, [698] = 8, [699] = 9, + }, + }, + + -- EVER GRANDE CITY: the massif behind the League is drawn as stacked + -- terraces, plateau over face, and is built from that drawing + -- (Structures.terraceGen3Mountains). The rock is the shared primary's: + -- tops 108/109/113/190, faces the rest. + EverGrandeCity = { + mountain_terraces = true, + mountain_walkable_tops = true, + mountain_rock = { + [108] = true, [109] = true, [113] = true, [190] = true, + [112] = true, [114] = true, [115] = true, [116] = true, + [117] = true, [120] = true, [121] = true, [122] = true, + [123] = true, [124] = true, [125] = true, [137] = true, + [145] = true, [169] = true, [378] = true, [380] = true, + }, + }, + + -- SKY PILLAR'S ISLAND: the same terraced massif; stood as a jumble of slabs. + SkyPillar_Outside = { + mountain_terraces = true, + mountain_tops = { + [113] = true, + }, + mountain_rock = { + [113] = true, [115] = true, [116] = true, [117] = true, [123] = true, + [124] = true, [125] = true, [137] = true, [169] = true, + }, + }, + -- MT PYRE'S FLANK: terraced like Ever Grande. + MtPyre_Exterior = { + elevation_height = true, drawn_terraces = false, + mountain_terraces = true, + mountain_tops = { + [105] = true, [107] = true, [109] = true, [113] = true, [191] = true, + }, + mountain_rock = { + [105] = true, [107] = true, [109] = true, [112] = true, [113] = true, + [114] = true, [115] = true, [116] = true, [117] = true, [120] = true, + [121] = true, [122] = true, [123] = true, [124] = true, [125] = true, + [137] = true, [139] = true, [140] = true, [145] = true, [169] = true, + [191] = true, + }, + }, + -- ROUTE 122: Mt Pyre's island rim, the terraced massif drawing; the sea + -- stacks (37x/38x) are left to the water-rock pass. + Route122 = { + mountain_terraces = true, + mountain_tops = { + [107] = true, [108] = true, [109] = true, [113] = true, [190] = true, + [191] = true, + }, + mountain_rock = { + [107] = true, [108] = true, [109] = true, [113] = true, [115] = true, + [116] = true, [117] = true, [123] = true, [124] = true, [125] = true, + [137] = true, [169] = true, [190] = true, [191] = true, + }, + }, + -- ROUTE 125: the Shoal Cave headland; stood as a jumble with black slabs. + Route125 = { + mountain_terraces = true, + mountain_tops = { + [107] = true, [108] = true, [109] = true, [113] = true, [190] = true, + [191] = true, + }, + mountain_rock = { + [107] = true, [108] = true, [109] = true, [113] = true, [115] = true, + [116] = true, [117] = true, [121] = true, [123] = true, [124] = true, + [125] = true, [137] = true, [138] = true, [145] = true, [169] = true, + [190] = true, [191] = true, + }, + }, + -- THE EVENT ISLANDS: map.json types them INDOOR, the art is outdoors. + FarawayIsland_Entrance = { outdoor = true }, + FarawayIsland_Interior = { outdoor = true }, + -- (Birth Island stays as typed: its harbour, outdoors, stood as two roof towers) + NavelRock_Exterior = { outdoor = true }, + + -- ROUTE 116'S EAST END, under Rusturf Tunnel: the same terraced massif + -- drawing as Ever Grande (and the same primary rock), which stood as a + -- jumble of rock towers up to the map's edge. + Route116 = { + mountain_terraces = true, + mountain_tops = { [108] = true, [109] = true, [113] = true, + [182] = true, [190] = true }, + mountain_rock = { + [108] = true, [109] = true, [113] = true, [182] = true, [190] = true, + [112] = true, [115] = true, [116] = true, [117] = true, + [123] = true, [124] = true, [125] = true, [137] = true, [169] = true, + }, + }, + -- ROUTE 112: Mt Chimney's red rock (Lavaridge's 617..639) and the + -- primary's grey, terraced like Ever Grande. + Route112 = { + mountain_terraces = true, + mountain_tops = { + [105] = true, [107] = true, [108] = true, [113] = true, [190] = true, + [617] = true, [620] = true, [639] = true, [790] = true, + }, + mountain_rock = { + [105] = true, [107] = true, [108] = true, [112] = true, [113] = true, + [115] = true, [190] = true, [332] = true, [592] = true, [593] = true, + [601] = true, [617] = true, [618] = true, [620] = true, [626] = true, + [627] = true, [628] = true, [629] = true, [633] = true, [634] = true, + [635] = true, [639] = true, [790] = true, + }, + }, + -- ROUTE 115: the tan terraced cliffs north of the beach. Its cells + -- stood at unrelated heights with the sky showing between them. + Route115 = { + mountain_terraces = true, + mountain_tops = { + [563] = true, [572] = true, + }, + mountain_rock = { + [136] = true, [137] = true, [144] = true, [378] = true, [381] = true, + [382] = true, [563] = true, [571] = true, [572] = true, [573] = true, + [576] = true, [579] = true, [581] = true, [585] = true, [587] = true, + [588] = true, [589] = true, [595] = true, [596] = true, [603] = true, + [604] = true, [611] = true, [612] = true, [613] = true, [848] = true, + [849] = true, + }, + }, + -- ROUTE 118: the massif on the north shore, which stood as two spires. + Route118 = { + mountain_terraces = true, + mountain_tops = { + [113] = true, + }, + mountain_rock = { + [112] = true, [113] = true, [114] = true, [115] = true, [116] = true, + [117] = true, [120] = true, [122] = true, [123] = true, [124] = true, + [125] = true, [137] = true, [145] = true, [169] = true, [513] = true, + [699] = true, + }, + }, + -- ROUTE 113: the ash-dusted massif along the north edge -- the primary's + -- rock with Fallarbor's ashy faces (521, 540, 543) and top (514). + Route113 = { + mountain_terraces = true, + -- ...and its free-standing mounds (513..531), rim over face like the + -- massif, which stood as loose blocks among the ash + mountain_tops = { [107] = true, [108] = true, [109] = true, [113] = true, + [190] = true, [191] = true, [513] = true, [514] = true, + [515] = true }, + mountain_rock = { + [107] = true, [108] = true, [109] = true, [113] = true, [190] = true, + [191] = true, [513] = true, [514] = true, [515] = true, + [117] = true, [121] = true, [123] = true, [124] = true, [125] = true, + [137] = true, [521] = true, [523] = true, [529] = true, [531] = true, + [540] = true, [543] = true, + }, + }, + -- ROUTE 114: the same drawing in Fallarbor's tan rock -- tops 577 and + -- its rims 563/564/569/572, faces the rest. + Route114 = { + mountain_terraces = true, + mountain_tops = { [563] = true, [564] = true, [569] = true, + [572] = true, [577] = true }, + mountain_rock = { + [563] = true, [564] = true, [569] = true, [572] = true, [577] = true, + [554] = true, [571] = true, [573] = true, [576] = true, [579] = true, + [580] = true, [581] = true, [589] = true, [613] = true, + }, + }, + + -- LITTLEROOT'S LAB, (3..9, 12..16). Three rows of roof, the skylight + -- in them (578/579 over 586/587), over two of facade -- windows, the + -- door. Emerald does not cap a building at its wall art, so it stood + -- 64px, a mound of roof over a two-storey wall of windows. Classed, + -- it stands the 32px of facade it draws. + LittlerootTown = { + building_art = { + [524] = "roof", [578] = "roof", [579] = "roof", [525] = "roof", + [526] = "roof", [532] = "roof", [586] = "roof", [587] = "roof", + [533] = "roof", [534] = "roof", + [542] = "wall", [527] = "wall", [556] = "wall", [577] = "wall", + [557] = "wall", [543] = "wall", [550] = "wall", [535] = "wall", + [564] = "wall", [585] = "wall", [565] = "wall", [551] = "wall", + }, + }, + + -- VERDANTURF'S BATTLE TENT, (1..5, 3..7). A dome drawn front-on: its + -- crown (946..958) over the emblem, the arch and the doors (962..982). + -- It stands against the cliff with a column of trees beside it and the + -- door walk claimed both -- 48 cells -- so it rose as a slab the height + -- of the cliff. Named and held to its art (`art_footprint`). + VerdanturfTown = { + art_footprint = true, + building_art = { + [946] = "roof", [947] = "roof", [948] = "roof", [949] = "roof", + [950] = "roof", [954] = "roof", [955] = "roof", [956] = "roof", + [957] = "roof", [958] = "roof", + [962] = "wall", [963] = "wall", [964] = "wall", [965] = "wall", + [966] = "wall", [970] = "wall", [971] = "wall", [972] = "wall", + [973] = "wall", [974] = "wall", [978] = "wall", [979] = "wall", + [980] = "wall", [981] = "wall", [982] = "wall", + }, + }, + + -- ROUTE 4 (FireRed): Mt Moon's massif, terraced like Ever Grande's -- + -- FireRed's primary draws the same plateau-over-face rock. + Route4 = { + mountain_terraces = true, + mountain_tops = { + [105] = true, [107] = true, [108] = true, [109] = true, [113] = true, + [129] = true, [186] = true, [187] = true, + }, + mountain_rock = { + [105] = true, [107] = true, [108] = true, [109] = true, [113] = true, + [114] = true, [115] = true, [117] = true, [121] = true, [122] = true, + [123] = true, [124] = true, [125] = true, [129] = true, [136] = true, + [138] = true, [141] = true, [178] = true, [179] = true, [186] = true, + [187] = true, + }, + }, + + -- ROUTE 20 (FireRed): the two Seafoam Islands, terraced rock drawn the + -- same way; one stood a dark slab the height of the whole island. + Route20 = { + mountain_terraces = true, + mountain_tops = { + [107] = true, [108] = true, [109] = true, [113] = true, [152] = true, + [153] = true, [154] = true, [186] = true, [187] = true, [271] = true, + }, + mountain_rock = { + [107] = true, [108] = true, [109] = true, [113] = true, [115] = true, + [117] = true, [121] = true, [123] = true, [124] = true, [125] = true, + [152] = true, [153] = true, [154] = true, [160] = true, [168] = true, + [170] = true, [178] = true, [179] = true, [186] = true, [187] = true, + [271] = true, [287] = true, + }, + }, + + -- KINDLE ROAD: Kanto's rock and Mt Ember's grey scree (849 top, 820..861 + -- faces), terraced; both stood as a jumble of slabs. + OneIsland_KindleRoad = { + mountain_terraces = true, + mountain_tops = { + [105] = true, [107] = true, [108] = true, [109] = true, [113] = true, + [152] = true, [153] = true, [154] = true, [186] = true, [187] = true, + [271] = true, [849] = true, + }, + mountain_rock = { + [105] = true, [107] = true, [108] = true, [109] = true, [112] = true, + [113] = true, [114] = true, [115] = true, [117] = true, [120] = true, + [121] = true, [122] = true, [123] = true, [124] = true, [125] = true, + [152] = true, [153] = true, [154] = true, [160] = true, [162] = true, + [168] = true, [170] = true, [178] = true, [179] = true, [186] = true, + [187] = true, [271] = true, [287] = true, [820] = true, [821] = true, + [822] = true, [823] = true, [836] = true, [848] = true, [849] = true, + [850] = true, [851] = true, [853] = true, [857] = true, [859] = true, + [860] = true, [861] = true, + }, + }, + -- TWO ISLAND: the cliff behind the town stood as one dark tower. + TwoIsland = { + mountain_terraces = true, + mountain_tops = { + [105] = true, [107] = true, [108] = true, [109] = true, [113] = true, + [152] = true, [153] = true, [154] = true, [186] = true, [187] = true, + [271] = true, + }, + mountain_rock = { + [105] = true, [107] = true, [108] = true, [109] = true, [112] = true, + [113] = true, [114] = true, [115] = true, [117] = true, [120] = true, + [121] = true, [122] = true, [123] = true, [124] = true, [125] = true, + [152] = true, [153] = true, [154] = true, [160] = true, [162] = true, + [168] = true, [170] = true, [178] = true, [179] = true, [186] = true, + [187] = true, [271] = true, [287] = true, + }, + }, + -- FOUR ISLAND: its hill and shore rock. + FourIsland = { + mountain_terraces = true, + mountain_tops = { + [105] = true, [107] = true, [108] = true, [109] = true, [113] = true, + [152] = true, [153] = true, [154] = true, [186] = true, [187] = true, + [271] = true, [773] = true, + }, + mountain_rock = { + [105] = true, [107] = true, [108] = true, [109] = true, [112] = true, + [113] = true, [114] = true, [115] = true, [117] = true, [120] = true, + [121] = true, [122] = true, [123] = true, [124] = true, [125] = true, + [152] = true, [153] = true, [154] = true, [160] = true, [162] = true, + [168] = true, [170] = true, [178] = true, [179] = true, [186] = true, + [187] = true, [271] = true, [287] = true, [773] = true, + }, + }, + + -- ROUTE 10'S POWER PLANT, (2..11, 34..40): three rows of pink roof over + -- three of glass front. Its west door's walk took the rock band above + -- it, so it stood as a striped tower; named and held to its art. + Route10 = { + art_footprint = true, + building_art = { + [797] = "roof", [645] = "roof", [646] = "roof", [647] = "roof", + [805] = "roof", [812] = "roof", [813] = "roof", [804] = "roof", + [814] = "roof", [661] = "roof", [662] = "roof", [659] = "roof", + [663] = "roof", + [724] = "wall", [766] = "wall", [792] = "wall", [793] = "wall", + [795] = "wall", [725] = "wall", [734] = "wall", [749] = "wall", + [732] = "wall", [774] = "wall", [800] = "wall", [801] = "wall", + [803] = "wall", [733] = "wall", [740] = "wall", [782] = "wall", + [808] = "wall", [809] = "wall", [794] = "wall", [810] = "wall", + [811] = "wall", [741] = "wall", + }, + }, + + LavenderTown = { + building_art = { + [721] = "wall", [722] = "wall", [723] = "wall", [726] = "wall", [727] = "wall", [729] = "wall", [735] = "wall", [737] = "wall", [738] = "wall", [739] = "wall", [742] = "wall", [743] = "wall", [745] = "wall", [746] = "wall", [747] = "wall", [750] = "wall", [751] = "wall", [807] = "wall", [767] = "wall", [824] = "wall", [825] = "wall", [826] = "wall", [820] = "wall", [815] = "wall", [775] = "wall", [832] = "wall", [834] = "wall", [821] = "wall", + }, + }, + -- ROUTE 111'S SAND RAMPS ARE A FLIGHT, AND THE CARTRIDGE SAYS SO. -- -- `MB_MUDDY_SLOPE` is Emerald's own word for a ramp: without the Acro @@ -2624,7 +3107,7 @@ gTileset_Sootopolis = { -- Pinned by MAP where the tileset is shared: gTileset_Facility is Mt -- Pyre's secondary but also twenty-seven other maps, and gTileset_Fortree -- covers Faraway Island's interior as well as the grass routes. - MtPyre_Exterior = { elevation_height = true, drawn_terraces = false }, + -- MtPyre_Exterior: see its entry above (one key per map) MtPyre_Summit = { elevation_height = true, drawn_terraces = false }, Route119 = { elevation_height = true, drawn_terraces = false }, diff --git a/mods/DRAMATIC_SHAPE/lib/ChunkMesher.lua b/mods/DRAMATIC_SHAPE/lib/ChunkMesher.lua index d8b24b7d4..d210b723f 100644 --- a/mods/DRAMATIC_SHAPE/lib/ChunkMesher.lua +++ b/mods/DRAMATIC_SHAPE/lib/ChunkMesher.lua @@ -864,6 +864,49 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink) return false end + -- THE FLOOR A CLUMP OF PROPS STANDS ON (Gen 3 outdoors). + -- + -- A Kanto tree is drawn two and three cells tall, and its upper cells are + -- stood up on the ground a course or two higher so the drawing climbs -- + -- which was right for the TREE and wrong for the ground cut out under it: + -- that fill was laid at the raised height too, with walls down to the + -- street, and Pewter's row of trees south of the Mart stood on a block of + -- pavement two courses tall. The fill belongs at the floor the clump + -- stands in: the commonest height of the flat, walkable-ground tiles + -- around the connected skipped region, never above the tile's own. + local clumpFloor = {} + local function propFloor(tx0, ty0) + local k0 = keyOf(tx0, ty0) + local hit = clumpFloor[k0] + if hit ~= nil then return hit or nil end + local members, queue, head, seen = {}, { { tx0, ty0 } }, 1, { [k0] = true } + local votes = {} + while head <= #queue and #queue < 2048 do + local c = queue[head]; head = head + 1 + members[#members + 1] = keyOf(c[1], c[2]) + for _, d in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }) do + local nx, ny = c[1] + d[1], c[2] + d[2] + local nk = keyOf(nx, ny) + if not seen[nk] then + seen[nk] = true + local ns = S.shapeAt[nk] + if S.skip[nk] and ns then + queue[#queue + 1] = { nx, ny } + elseif ns and ns.flat and ns.class ~= "water" and ns.class ~= "void" then + local h = heightAt(nx, ny) + if type(h) == "number" then votes[h] = (votes[h] or 0) + 1 end + end + end + end + end + local best, bestN = nil, 0 + for h, n in pairs(votes) do + if n > bestN or (n == bestN and best and h < best) then best, bestN = h, n end + end + for _, mk in ipairs(members) do clumpFloor[mk] = best or false end + return best + end + for ty = -r, th + r - 1 do for tx = -r, tw + r - 1 do Budget.tick() @@ -938,6 +981,10 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink) -- already using. local gy = heightAt(tx, ty) if type(gy) ~= "number" then gy = s.base or 0 end + if S.isGen3 and S.outdoor then + local pf = propFloor(tx, ty) + if pf and pf < gy then gy = pf end + end topQuad(tx * 8, ty * 8, gy, g, 1) -- the claimed tile is still ground at height 0, and water next -- door still recesses below it: without the same below-ground @@ -1535,6 +1582,16 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink) -- `run.north` on any of the 518 maps (measured: 0 cases). artTop = run.roofArtTop m = math.min(run.roofArtRows, run.front - artTop + 1) + elseif S.isGen3 and run.gen3FlatTop then + artTop = run.gen3FlatTop[1] + m = math.max(1, run.gen3FlatTop[2]) + elseif S.isGen3 and (run.gen3FlatRows or 0) > 0 then + -- A FLAT ROOFTOP WEARS ITS OWN FIELD, measured by Structures: + -- the rows from the building's top down to where its wall + -- starts. Left at the two-row default, a Kanto gym's top + -- sampled its facade too and wore the entrance emblem twice. + artTop = run.north + m = math.max(1, math.min(run.gen3FlatRows, run.front - artTop + 1)) end if roomTop then topQuad(x0, z0, h, roomTop, VOLUME_TOP_SHADE) diff --git a/mods/DRAMATIC_SHAPE/lib/Gen3.lua b/mods/DRAMATIC_SHAPE/lib/Gen3.lua index 29f3b6a97..a251abaf5 100644 --- a/mods/DRAMATIC_SHAPE/lib/Gen3.lua +++ b/mods/DRAMATIC_SHAPE/lib/Gen3.lua @@ -3797,7 +3797,11 @@ function Gen3.forMap(map) local function overlay(from) if type(from) ~= "table" then return end for k, v in pairs(from) do - if (k == "joinery" or k == "roof_bands") and type(v) == "table" then + -- the art lists merge too: a map naming its own buildings must not + -- take away the ones its tileset names (Saffron's Dojo and the gate + -- houses every Kanto route shares) + if (k == "joinery" or k == "roof_bands" or k == "building_art" + or k == "doorless_art" or k == "elevation_art") and type(v) == "table" then local entries = type(profile[k]) == "table" and profile[k] or {} for meta, model in pairs(v) do entries[meta] = model end profile[k] = entries @@ -3811,6 +3815,11 @@ function Gen3.forMap(map) overlay(spec_.tilesets[ctx.secondaryName]) end if spec_ and spec_.maps then overlay(spec_.maps[ctx.mapName]) end + -- ...AND A PROFILE MAY SAY A MAP IS OUT IN THE OPEN. The generated table + -- reads map.json's map_type, and Emerald types its event islands (Faraway, + -- Birth, Navel Rock) INDOOR -- so Faraway Island's jungle was meshed as a + -- room: every tree on a dirt plinth a course over the path. + if profile.outdoor ~= nil then ctx.outdoor = profile.outdoor and true or false end -- METATILE PINS, primary then secondary then the map: most general to most -- specific, each overlaying the last, the same order as the profile itself. local pins = nil @@ -3850,6 +3859,24 @@ function Gen3.forMap(map) end ctx.buildingArt = next(t) and t or nil end + local dl = profile.doorless_art + if type(dl) == "table" then + local t = {} + for id, on in pairs(dl) do + local n = tonumber(id) + if n and on then t[n] = true end + end + ctx.doorlessArt = next(t) and t or nil + end + local ev = profile.elevation_art + if type(ev) == "table" then + local t = {} + for id, on in pairs(ev) do + local n = tonumber(id) + if n and on then t[n] = true end + end + ctx.elevationArt = next(t) and t or nil + end end ctx.profile = profile @@ -4773,8 +4800,7 @@ function Gen3.forMap(map) local an = Gen3.analyse(map.tileset) local st = an and an.stats and an.stats[mm] if not (st and (st.solid or 0) >= 0.9) then return false end - local counts = ctx.metatileCounts() - return (counts[mm] or 0) <= RARE_BUILD + return ctx.metatileBespoke(mm) end -- THE BUILDING'S OWN ART, as this function reads it everywhere else: -- "a building's art is BESPOKE -- the Mart's metatiles appear once or @@ -4786,8 +4812,7 @@ function Gen3.forMap(map) local function bespokeCell(cx, cy) local mm = ctx.metatileAt(cx, cy) if mm == nil then return false end - local counts = ctx.metatileCounts() - return (counts[mm] or 0) <= RARE_BUILD + return ctx.metatileBespoke(mm) end -- ...and drawn ABOVE THE PLAYER, filling its cell, and bespoke: the -- three-part test for "a row of this building you walk behind". @@ -4922,6 +4947,45 @@ function Gen3.forMap(map) return metaCounts end + -- ...AND A TILE THAT TILES ONLY INSIDE ONE BUILDING IS STILL BESPOKE. + -- + -- MOTIVATED BY LAVENDER TOWN'S POKEMON TOWER, (15..21, 0..6): its window + -- band 723 is laid three wide on three storeys -- nine copies, over the + -- RARE_BUILD four -- so the trim read the tower's whole upper body as + -- landscape and kept one row of it: a two-course box under a tower + -- painted flat. Rock and trees tile across the map; a building's + -- repeated band never leaves the building. Every copy inside one + -- building-sized box (twelve cells either way) is the building's own. + local metaBox = nil + function ctx.metatileClustered(m) + if m == nil then return false end + if not metaBox then + metaBox = {} + for cy = 0, height - 1 do + for cx = 0, width - 1 do + local mm = ctx.metatileAt(cx, cy) + if mm then + local b = metaBox[mm] + if not b then metaBox[mm] = { cx, cy, cx, cy } + else + if cx < b[1] then b[1] = cx end + if cy < b[2] then b[2] = cy end + if cx > b[3] then b[3] = cx end + if cy > b[4] then b[4] = cy end + end + end + end + end + end + local b = metaBox[m] + return b ~= nil and (b[3] - b[1]) < 12 and (b[4] - b[2]) < 12 + end + function ctx.metatileBespoke(m) + if m == nil then return false end + local counts = ctx.metatileCounts() + return (counts[m] or 0) <= RARE_BUILD or ctx.metatileClustered(m) + end + -- (`RARE_BUILD` used to be declared here. It moved above `runAbove`, -- which now needs it -- see A DOORWAY YOU WALK INTO IS STILL A DOORWAY -- IN A BUILDING. Same value, same readers.) @@ -4965,7 +5029,25 @@ function Gen3.forMap(map) warps = rec and rec.warps or nil if not warps and type(map.def) == "table" then warps = map.def.warps end end - if type(warps) ~= "table" then return built end + -- a map with no warps can still draw a building whose door is on the + -- next map (the doorless passes at the end); it just has no doors + if type(warps) ~= "table" then + if not (ctx.doorlessArt or ctx.elevationArt) then return built end + warps = {} + end + -- A CAVE MOUTH IS NOT A FRONT DOOR. Rock Tunnel's south entrance on + -- Route 10 opens into the rock above it, and founded as a building that + -- rock -- 101 cells of it -- stood as one block 144px tall over the + -- river, walling the route off from the camera. A warp into a map the + -- cartridge types as a cave leads into the landscape, not a house. + do + local kept = {} + for _, wp in ipairs(warps) do + local dest = maps and maps.maps and maps.maps[tostring(wp.destMap)] + if not (dest and dest.kind == "cave") then kept[#kept + 1] = wp end + end + warps = kept + end -- WHERE THE ROOF IS, THE BUILDING IS. -- -- `runAbove` walks blocked cells, and outdoors that walk does not stop @@ -5336,7 +5418,7 @@ function Gen3.forMap(map) if mm and ctx.buildingArt and ctx.buildingArt[mm] then break end - if mm and (counts[mm] or 0) > RARE_BUILD then t = t + 1 + if mm and not ctx.metatileBespoke(mm) then t = t + 1 else break end end end @@ -6016,6 +6098,48 @@ function Gen3.forMap(map) end cells = keep end + -- ...AND A BUILDING THAT GOES ON ABOVE ITS AWNING IS STILL THAT + -- BUILDING. Lavender's Pokemon Tower states a roof only on the + -- awning over its door (825, row 5); the five storeys above it + -- are blocked, solid, and the tower's own art (Gen3's + -- `metatileBespoke`), and the footprint stopped at the awning: a + -- one-row building under a tower painted flat. Each column of + -- the finished footprint climbs on through cells that are + -- building -- never foliage, never another footprint -- and the + -- climb only counts when it is two rows or more. + if #cells > 0 and ctx.outdoor then + local an = Gen3.analyse(map.tileset) + local colTop, inSet = {}, {} + for _, c in ipairs(cells) do + inSet[c[2] * 8192 + c[1]] = true + if colTop[c[1]] == nil or c[2] < colTop[c[1]] then colTop[c[1]] = c[2] end + end + local grown = {} + local function buildingArt(cx, y) + local mm = ctx.metatileAt(cx, y) + local st = mm and an and an.stats and an.stats[mm] + return blockedCell(cx, y) and st and (st.solid or 0) >= 0.9 + and ctx.metatileBespoke(mm) and not foliageCell(cx, y) + and built.cell[y * 8192 + cx] == nil + end + for cx, top in pairs(colTop) do + local y = top - 1 + local add = {} + while y >= 0 and #add < 8 and buildingArt(cx, y) do + add[#add + 1] = { cx, y } + y = y - 1 + end + if #add >= 2 then + for _, c in ipairs(add) do grown[#grown + 1] = c end + end + end + for _, c in ipairs(grown) do + if not inSet[c[2] * 8192 + c[1]] then + cells[#cells + 1] = c + inSet[c[2] * 8192 + c[1]] = true + end + end + end if #cells > 0 then -- HOW MANY ROWS OF THIS BUILDING ARE DRAWN ABOVE THE PLAYER. -- @@ -6236,6 +6360,213 @@ function Gen3.forMap(map) end end end + end + -- A DOOR ON ANY FACE OF ART THE PROFILE NAMES AS A BUILDING. + -- + -- Everything above finds a building NORTH of its door, which is how a + -- house is drawn. A route gate is entered on whichever face the road + -- meets: Saffron's south gate from the north, its east and west gates + -- from the side. Where the profile's `building_art` names the cells + -- beside such a door, the connected mass of them is the building. No + -- art test, no guess: only ids a map's profile has written down. + if ctx.buildingArt then + for _, wp in ipairs(warps) do + local wx, wy = tonumber(wp.x), tonumber(wp.y) + if wx and wy and built.cell[wy * 8192 + wx] == nil then + local function named(x, y) + if x < 0 or y < 0 or x >= width or y >= height then return false end + if built.cell[y * 8192 + x] ~= nil then return false end + local mm = ctx.metatileAt(x, y) + return mm ~= nil and ctx.buildingArt[mm] ~= nil + end + local seed = nil + for _, d in ipairs({ { 0, -1 }, { 0, 1 }, { -1, 0 }, { 1, 0 } }) do + if not seed and named(wx + d[1], wy + d[2]) then seed = { wx + d[1], wy + d[2] } end + end + if seed then + local cells, seen, queue, head = {}, { [seed[2] * 8192 + seed[1]] = true }, { seed }, 1 + while head <= #queue and #queue <= 160 do + local c = queue[head]; head = head + 1 + cells[#cells + 1] = c + for _, d in ipairs({ { 0, -1 }, { 0, 1 }, { -1, 0 }, { 1, 0 } }) do + local nx, ny = c[1] + d[1], c[2] + d[2] + local k = ny * 8192 + nx + if not seen[k] and named(nx, ny) then + seen[k] = true + queue[#queue + 1] = { nx, ny } + end + end + end + if #cells >= 4 then + -- the stated door is the bottom-middle cell, the convention + -- the doorless pass above uses: its {0,1} is the street a + -- house stands on, which is what every reader of `door` wants + local x0, x1, yb = 1e9, -1e9, -1 + for _, c in ipairs(cells) do + if c[2] > yb then yb = c[2] end + if c[1] < x0 then x0 = c[1] end + if c[1] > x1 then x1 = c[1] end + end + local id = #built.list + 1 + for _, c in ipairs(cells) do built.cell[c[2] * 8192 + c[1]] = id end + built.list[id] = { cells = cells, door = { x0 + math.floor((x1 - x0) / 2), yb }, + warp = { wx, wy }, + overheadRows = 0, roofed = true } + end + end + end + end + end + local function foundDoorless(art, minRows, elevation) + -- ...AND A FRONT-VIEW MASS NEEDS NO DOOR AT ALL. Every connected + -- run of `elevation` art no building has claimed is a building of its + -- own, stood up whole (see Structures' stand-up rule). + for cy = 0, height - 1 do + for cx = 0, width - 1 do + local k0 = cy * 8192 + cx + local m0 = ctx.metatileAt(cx, cy) + if built.cell[k0] == nil and m0 and art[m0] + and blockedCell(cx, cy) then + local cells, queue, head, seen = {}, { { cx, cy } }, 1, { [k0] = true } + while head <= #queue and #queue <= 200 do + local c = queue[head]; head = head + 1 + cells[#cells + 1] = c + for _, d in ipairs({ { 0, -1 }, { 0, 1 }, { -1, 0 }, { 1, 0 } }) do + local nx, ny = c[1] + d[1], c[2] + d[2] + local k = ny * 8192 + nx + local mm = (nx >= 0 and ny >= 0 and nx < width and ny < height) and ctx.metatileAt(nx, ny) + if not seen[k] and mm and art[mm] + and built.cell[k] == nil and blockedCell(nx, ny) then + seen[k] = true + queue[#queue + 1] = { nx, ny } + end + end + end + local yt, ybb = 1e9, -1 + for _, c in ipairs(cells) do if c[2] < yt then yt = c[2] end; if c[2] > ybb then ybb = c[2] end end + if #cells >= 4 and ybb - yt + 1 >= minRows then + local x0, x1, yb = 1e9, -1e9, -1 + for _, c in ipairs(cells) do + if c[2] > yb then yb = c[2] end + if c[1] < x0 then x0 = c[1] end + if c[1] > x1 then x1 = c[1] end + end + local id = #built.list + 1 + for _, c in ipairs(cells) do built.cell[c[2] * 8192 + c[1]] = id end + built.list[id] = { cells = cells, door = { x0 + math.floor((x1 - x0) / 2), yb }, + overheadRows = 0, roofed = not elevation, elevation = elevation } + end + end + end + end + end + -- ...AND EVERY DOOR A BUILDING HOLDS WIDENS IT, NOT ONLY THE FIRST. + -- + -- MOTIVATED BY LAVENDER TOWN'S SOUTH HOUSES, doors (5,16) and (10,16): + -- the first door's widening walked five cells each way and swallowed + -- the second house's door, so the second warp found its door already + -- claimed and grew nothing -- and the second house's east two columns + -- were never a building at all. They stood as low rock: a sunken step + -- in the roof. From every warp whose door is inside a building, walk + -- each of the building's rows outward through blocked cells wearing art + -- the building already wears. + if ctx.outdoor then + for _, wp in ipairs(warps) do + local wx, wy = tonumber(wp.x), tonumber(wp.y) + local id = wx and wy and built.cell[wy * 8192 + wx] + if not id and wx and wy then + for dy = 1, 2 do id = id or built.cell[(wy - dy) * 8192 + wx] end + end + local bl = id and built.list[id] + if bl then + local art, rows = {}, {} + for _, c in ipairs(bl.cells) do + local m = ctx.metatileAt(c[1], c[2]) + if m then art[m] = true end + rows[c[2]] = true + end + for y in pairs(rows) do + for _, dir in ipairs({ -1, 1 }) do + local x = wx + for _ = 1, 6 do + x = x + dir + if x < 0 or x >= width then break end + local k = y * 8192 + x + local owner = built.cell[k] + if owner and owner ~= id then break end + if not owner then + local m = ctx.metatileAt(x, y) + if not (m and art[m] and blockedCell(x, y)) then break end + built.cell[k] = id + bl.cells[#bl.cells + 1] = { x, y } + end + end + end + end + end + end + end + if ctx.elevationArt then foundDoorless(ctx.elevationArt, 6, true) end + -- ...AND A BUILDING WHOSE DOOR IS ON THE NEXT MAP. The Sevii harbours + -- stand on the sea map below their island, and their door warp is on + -- the island: the map that draws them has no warp at all, so nothing + -- founded them and each came out as a stepped tower of rock. + if ctx.doorlessArt then + -- ...AND A DOORED BUILDING GIVES BACK THE CELLS IT GREW INTO. Its + -- widening walks along matching rows, and Celadon's Pokemon Centre + -- took three columns of the apartment block beside it. Art a map + -- names as doorless belongs to no doored building -- unless that + -- building's own door is drawn in it (the Condominiums'). + for id, bl in ipairs(built.list) do + if bl.door and not bl.elevation then + local dx, dy = bl.door[1], bl.door[2] + local above = ctx.metatileAt(dx, dy - 1) + local own = ctx.metatileAt(dx, dy) + local function ownArt(m) + return m ~= nil and (ctx.doorlessArt[m] or (ctx.elevationArt and ctx.elevationArt[m])) + end + if not (ownArt(above) or ownArt(own)) then + local keep = {} + for _, c in ipairs(bl.cells) do + local m = ctx.metatileAt(c[1], c[2]) + if m and ctx.doorlessArt[m] then + built.cell[c[2] * 8192 + c[1]] = nil + else + keep[#keep + 1] = c + end + end + bl.cells = keep + end + end + end + foundDoorless(ctx.doorlessArt, 1, false) + end + -- ...AND WHERE THE PROFILE SAYS SO, A BUILDING IS ITS ART AND NOTHING + -- ELSE. The north-of-door walk climbs every blocked row above a door, + -- and Verdanturf's Battle Tent stands against the cliff with a column + -- of trees beside it: the walk took both, 48 cells for a 25-cell tent, + -- and the tent stood as a cliff-high slab. `art_footprint` is opt-in: + -- elsewhere a profile may name only part of a building on purpose. + if ctx.profile and ctx.profile.art_footprint and ctx.buildingArt then + for _, bl in ipairs(built.list) do + local keep, named = {}, 0 + for _, c in ipairs(bl.cells) do + local m = ctx.metatileAt(c[1], c[2]) + if m and ctx.buildingArt[m] then + named = named + 1 + keep[#keep + 1] = c + end + end + if named >= 4 and named < #bl.cells then + for _, c in ipairs(bl.cells) do + local m = ctx.metatileAt(c[1], c[2]) + if not (m and ctx.buildingArt[m]) then + built.cell[c[2] * 8192 + c[1]] = nil + end + end + bl.cells = keep + end + end end return built end diff --git a/mods/DRAMATIC_SHAPE/lib/ModSetting.lua b/mods/DRAMATIC_SHAPE/lib/ModSetting.lua index 2bed60578..b282ca6ce 100644 --- a/mods/DRAMATIC_SHAPE/lib/ModSetting.lua +++ b/mods/DRAMATIC_SHAPE/lib/ModSetting.lua @@ -88,6 +88,7 @@ function ModSetting:setIndex(i, game) loader.modOptions[id][self.key] = value end if game and game.writeOptions then pcall(game.writeOptions, game) end + if self.onChange then pcall(self.onChange, value) end return value end @@ -99,7 +100,9 @@ end -- which writes and persists on its own). Nothing to store: just move the -- cached index so the next read agrees with it. function ModSetting:sync(value) + local before = self.index self.index = indexOf(self, value) + if self.onChange and before ~= self.index then pcall(self.onChange, value) end end -- The descriptor src/ui/OptionRows.lua renders, in the shape the diff --git a/mods/DRAMATIC_SHAPE/lib/Structures.lua b/mods/DRAMATIC_SHAPE/lib/Structures.lua index eb1665ccc..2b2b796c9 100644 --- a/mods/DRAMATIC_SHAPE/lib/Structures.lua +++ b/mods/DRAMATIC_SHAPE/lib/Structures.lua @@ -365,7 +365,45 @@ local GEN3_MAX_LAND = 32 -- footprint is 0 rows for 341 of them, 1 for one, and 5 and 6 for the two -- that are towers: Silph Co and the BATTLE TOWER. Both now stand (48 -> 128, -- 64 -> 144); every other building in either region is unmoved. -Structures.SHAPE_REV = "g3-eave-394" +Structures.SHAPE_REV = "g3-event-islands-448" + +-- LEDGE TERRACES: whether a Gen 3 hop-down ledge raises the ground behind it +-- into a terrace (Structures.standGen3Ledges). OFF by default -- reported: +-- "turn off the hoppable ledges raising the ground, make it an option". +-- With it off the ledge keeps its own lip and the ground stays at the map's +-- elevation. The value is folded into `shapeRev()` so the disk cache keys +-- the two worlds apart and a change rebuilds instead of reading the other. +Structures.ledgeSetting = V.require("ModSetting").new( + "ledgeterraces", "LEDGE TERRACES", { false, true }, { "OFF", "ON" }) +function Structures.ledgeTerraces() + local ok, v = pcall(Structures.ledgeSetting.get, Structures.ledgeSetting) + return ok and v == true +end + +-- THE NAME OF THE MAP A GEN 3 BUILDING'S DOOR LEADS TO, or nil: the +-- building's door cell (Gen3.buildingsOf), the map's warp on that cell, and +-- the destination's name from data/gen3_maps.lua ("CeruleanCity_Gym"). +local destNames = nil +local function gen3BuildingDest(map, bldId) + local okS, bsp = pcall(Gen3.buildingsOf, map) + local rec = okS and bsp and bsp.list and bsp.list[bldId] + local door = rec and (rec.warp or rec.door) + if not door then return nil end + local warps = map.def and map.def.warps + if type(warps) ~= "table" then return nil end + for _, w in ipairs(warps) do + if tonumber(w.x) == door[1] and tonumber(w.y) == door[2] then + if destNames == nil then + local okM, m = pcall(V.data, "gen3_maps") + destNames = (okM and m and m.maps) or false + end + local d = destNames and destNames[tostring(w.destMap)] + return d and d.name or nil + end + end + return nil +end + -- one cell of world height: the step a building may straddle and still be -- treated as having one foundation local COURSE = 16 @@ -677,6 +715,7 @@ local DIRS4 = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } } local DIRS8 = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 }, { 1, 1 }, { 1, -1 }, { -1, 1 }, { -1, -1 } } + local function keyOf(tx, ty) return (ty + 64) * 4096 + (tx + 64) end @@ -6990,6 +7029,7 @@ function Structures.foundGen3Buildings(S, map, x0, x1, y0, y1) -- warp data this pass does nothing and every run keeps its own measurement, -- so Gen 1, Gen 2 and Prism are untouched. local bldFacade, bldBase, bldOwnZ, levelled = {}, {}, {}, 0 + local bldFlatFacade = {} local bldOverhead, bldRows, bldRoofRows = {}, {}, {} local bldRoofBands = {} -- ...AND NO BUILDING IS FOUNDED BELOW ITS OWN DOORSTEP. @@ -7197,6 +7237,14 @@ function Structures.foundGen3Buildings(S, map, x0, x1, y0, y1) -- (g3-mart-182): before that its columns were never levelled -- together, so each kept its own art height and nothing repeated. local m = (e.run.h or 0) - (e.run.base or 0) + (e.run.rise or 0) + if e.run.gen3FlatTop then + -- A FLAT ROOFTOP'S FACADE WAS ALREADY LEVELLED by buildVolume + -- and is the building's: a column of it buildVolume met in + -- another region, unlevelled, must not out-vote it. + if bldFlatFacade[e.bld] == nil or m > bldFlatFacade[e.bld] then + bldFlatFacade[e.bld] = m + end + end if bldFacade[e.bld] == nil or m > bldFacade[e.bld] then bldFacade[e.bld] = m end @@ -7208,7 +7256,12 @@ function Structures.foundGen3Buildings(S, map, x0, x1, y0, y1) -- over one blocked row and measured 32 against a drawing 96 tall -- -- a box on the sand. `ctx.buildings` counts those rows when it -- grows the footprint through them; each is a course of building. - if bldOverhead[e.bld] and bldOverhead[e.bld] > 0 then + -- ...BUT NOT FOR A FLAT ROOFTOP. Its above-player rows are the + -- roof lying on top, already measured as roof by buildVolume; read + -- as facade, Saffron's Dojo and Gym grew a course per row and + -- stood as towers with their emblems stacked up the front. + if bldOverhead[e.bld] and bldOverhead[e.bld] > 0 + and not e.run.gen3FlatTop then local want = m + bldOverhead[e.bld] * COURSE if want > bldFacade[e.bld] then bldFacade[e.bld] = want end end @@ -7301,6 +7354,20 @@ function Structures.foundGen3Buildings(S, map, x0, x1, y0, y1) if z and (ownZ == nil or z > ownZ) then ownZ = z end end if e.bld and bldOwnZ[e.bld] then ownZ = bldOwnZ[e.bld] end + -- ...AND NEVER ABOVE THE FLOOR ITS DOOR OPENS ONTO. + -- + -- MOTIVATED BY PEWTER CITY'S MART, (13..14, 15..18): a town of ONE + -- elevation level, its door opening onto the street at 0, and the + -- synthesized floor under its footprint reading 32 -- so it was founded + -- two courses up, and its facade, stretched from the street to that + -- plinth, wore its door and its MART sign three times over. "Some marts + -- look like the doors are repeating their textures." The Mossdeep rule + -- above (lowest cell, not highest) already says a building stands on its + -- door's floor where it can; this makes it the ceiling as well as the + -- floor whenever the door's floor is known. + if ownZ and e.bld and bldDoor[e.bld] and ownZ > bldDoor[e.bld] then + ownZ = bldDoor[e.bld] + end if ownZ then zBase = ownZ end -- the doorstep is a floor, not a suggestion if e.bld and bldDoor[e.bld] and zBase < bldDoor[e.bld] then @@ -7323,7 +7390,10 @@ function Structures.foundGen3Buildings(S, map, x0, x1, y0, y1) end local measured = (run.h or 0) - (run.base or 0) + (run.rise or 0) - if e.bld and bldFacade[e.bld] and bldFacade[e.bld] ~= measured then + if e.bld and bldFlatFacade[e.bld] then + measured = bldFlatFacade[e.bld] + if (run.rise or 0) > 0 then run.rise = 0 end + elseif e.bld and bldFacade[e.bld] and bldFacade[e.bld] ~= measured then measured = bldFacade[e.bld] levelled = levelled + 1 end @@ -7516,6 +7586,151 @@ function Structures.foundGen3Buildings(S, map, x0, x1, y0, y1) run.base = zBase run.peak = measured + zBase run.h = measured - (run.rise or 0) + zBase + -- ...AND A KANTO CENTRE OR MART IS TWO CELLS OF WALL UNDER ITS HIP. + -- + -- Reported: "the fronts of the pc and marts textures aren't right and + -- the pokeball tile is repeating ... the doors are repeating their + -- textures too". Both are drawn as a hip roof over a wall band two + -- cells deep -- sign, emblem plate, door -- and neither states its roof + -- on the above-player layer, so nothing above could tell roof from wall: + -- the Mart measured 48px of facade over 32px of wall art and the Centre + -- 64, and the fold stacked the MART sign and the Poke Ball plate up the + -- front to fill it. The footprint's last FOUR tile rows are the wall, + -- everything north of them is roof art on the hip. + -- A BUILDING STOPS AT THE MAP'S EDGE. Lavender's Pokemon Tower stands + -- against the north edge, and its column ran on into the border ring + -- above -- north -12, twenty-six rows of art for a seven-row drawing -- + -- so the facade was sampled over the wrong rows and wore its door twice. + -- Every cell the cartridge names for a building is on the map. + if S.isGen3 and S.outdoor and e.bld and run.north and run.north < 0 + and run.front and run.front >= 0 then + run.north = 0 + run.extent = run.front + 1 + if run.roofArtTop and run.roofArtTop < 0 then + run.roofArtRows = (run.roofArtRows or 0) + run.roofArtTop + run.roofArtTop = 0 + if run.roofArtRows <= 0 then run.roofArtTop, run.roofArtRows = nil, nil end + end + if run.gen3FlatTop and run.gen3FlatTop[1] < 0 then + run.gen3FlatTop = { 0, math.max(1, run.gen3FlatTop[2] + run.gen3FlatTop[1]) } + end + end + -- A BUILDING DRAWN AS AN ELEVATION IS STOOD UP AS ONE. + -- + -- Reported: "the cemetery building in lavender doesn't have the proper + -- facade to match the 2d art stood up in 3d". Lavender's Pokemon Tower + -- is drawn FRONT-ON -- storeys of windows stacked up the page, the door + -- at the foot -- not as a roof seen from above. Its whole drawing is the + -- facade: one course per tile row from the door to the top, no hip, no + -- roof band, and the top face is the top row. + if S.isGen3 and S.outdoor and e.bld and run.north and run.front then + local dnS = gen3BuildingDest(map, e.bld) + local elev = false + do + local okB, bsp = pcall(Gen3.buildingsOf, map) + local rec = okB and bsp and bsp.list and bsp.list[e.bld] + elev = rec and rec.elevation == true or false + end + if elev or (dnS and Structures.standUpDest(dnS)) then + local rows = run.front - run.north + 1 + -- never taller than the footprint the cartridge names: a column that + -- ran on past the building (into the cliff beside it) is not tower + local own = bldRows[e.bld] and bldRows[e.bld] * 2 or rows + if rows > own then rows = own end + run.rise = 0 + run.h = rows * 8 + zBase + run.peak = run.h + run.roofArtTop, run.roofArtRows = nil, nil + run.gen3FlatTop, run.gen3FlatRows = nil, nil + run.gen3ArtNorth = nil + end + end + -- A COLUMN WHOSE ART THE PROFILE HAS CLASSED IS MEASURED BY IT. + -- + -- Where `building_art` says which cells are "roof" and which "wall" + -- (the Kanto gate houses, the Sevii harbours), that IS the drawing's + -- split: the facade is the wall cells and nothing else, and a flat + -- top wears exactly the roof cells. Read off the run's own column. + -- Measured, Route 16's gate stood 64px of facade over 32px of wall and + -- folded its roof eave and window band across its front out of order. + if S.isGen3 and S.outdoor and e.bld and run.north and run.front + and g3c and g3c.buildingArt and g3c.metatileAt then + local cx = e.list and e.list[1] and e.list[1][1] or 0 + local wallC, roofTop, roofC = 0, nil, 0 + for cy = math.floor(math.max(0, run.north) / 2), math.floor(run.front / 2) do + local okM, m = pcall(g3c.metatileAt, cx, cy) + local cls = okM and m and g3c.buildingArt[m] + if cls == "wall" then wallC = wallC + 1 + elseif cls == "roof" then + roofC = roofC + 2 + if roofTop == nil then roofTop = cy * 2 end + elseif cls == "eave" then + -- only the cell's LOWER tile row is roof: the upper is the crown + -- of a tree behind the building, drawn over it (Saffron's Dojo) + roofC = roofC + 1 + if roofTop == nil then roofTop = cy * 2 + 1 end + end + end + if wallC > 0 and roofC > 0 then + run.h = wallC * 16 + zBase + -- a building no warp leads into (a gate wall, a doorless block) has + -- no pitched roof to keep: its named roof cells are a flat top + -- ...and one whose destination has a flat rooftop is flat in EVERY + -- column: a column the scan ran a row longer (past a signpost at the + -- door, Route 5's Underground Path hut) measured a pitch of its own + -- and stood as a grey slab beside its flat-topped neighbours + local destB = gen3BuildingDest(map, e.bld) + if run.gen3FlatTop or (run.rise or 0) == 0 or destB == nil + or Structures.flatRoofDest(destB) then + run.rise = 0 + run.peak = run.h + run.gen3FlatTop = { roofTop, roofC } + run.gen3FlatRows = (roofTop + roofC) - run.north + run.roofArtTop, run.roofArtRows = nil, nil + else + run.peak = run.h + run.rise + run.roofArtTop, run.roofArtRows = roofTop, roofC + end + end + end + if S.isGen3 and S.outdoor and e.bld and run.north and run.front + and require("src.core.GameVersion").get() ~= "emerald" then + local dn = gen3BuildingDest(map, e.bld) + local civic = dn and (dn:find("_Mart$") or dn:find("_PokemonCenter_1F$")) + -- EVERY OTHER BUILDING: no taller than the wall it has art for. + -- + -- Lavender's houses are a flat purple roof over ONE cell of wall -- + -- windows and door -- and stood 48px of facade over that 16px band, so + -- the window row was drawn twice and the door's top reappeared up the + -- front. Where the roof art's band is known, what is left below it is + -- the wall's whole drawing, and the facade stops there. + if not civic and not run.gen3FlatTop and run.roofArtTop + and (run.roofArtRows or 0) > 0 then + local wallRows = run.front - (run.roofArtTop + run.roofArtRows) + 1 + -- a wall is at least one cell: a band left a single tile row deep + -- had the door's top half on the roof and its bottom on the wall + if wallRows < 2 and run.roofArtRows > (2 - wallRows) then + run.roofArtRows = run.roofArtRows - (2 - wallRows) + wallRows = 2 + end + local cap = math.max(2, wallRows) * 8 + if run.h - zBase > cap then + run.h = cap + zBase + run.peak = run.h + (run.rise or 0) + end + end + if civic then + local WALL_ROWS = 4 + local roofRowsArt = (run.front - run.north + 1) - WALL_ROWS + local rise = (run.rise and run.rise > 0) and run.rise or COURSE + run.rise = rise + run.h = WALL_ROWS * 8 + zBase + run.peak = run.h + rise + if roofRowsArt > 0 then + run.roofArtTop, run.roofArtRows = run.north, roofRowsArt + end + end + end if S.outdoor and e.bld and (run.rise or 0) > 0 then local columns = bldRoofBands[e.bld] run.gen3RoofSplitPending = columns and columns[e.list[1][1]] @@ -8059,6 +8274,270 @@ end -- -- ANYTHING ALREADY HIGHER. Raise only. A cell the terrace pass or the -- rock pass put above this is better informed than a step count. +-- ---- mountain terraces: the drawing's own steps, read up each column ---- + +-- EVER GRANDE CITY'S MOUNTAINS. The city sits under Victory Road's massif, +-- and the cartridge draws it as stacked terraces seen from the south: a +-- light plateau top (108, 109, 113, 190), then a band of dark cliff face +-- (112..125, 137, 145, 169, 378, 380 -- 115 is a lip over a face), then the +-- next plateau above it, band after band up to the map's edge. Every one of +-- those cells is blocked, so the drop pass cut each column to the drop it +-- separated from whatever it happened to meet and the climb pass stepped +-- them one at a time: a jumble of boxes at unrelated heights, with plateau +-- art on cliff sides and cliff art lying on tops. +-- +-- The art states the heights outright, and it states them per column: walk +-- north from the ground a column stands on, and every face row climbs one +-- course while every plateau row holds level. A face cell stands at the +-- top of the course it draws, so its south side is the wall the art shows; +-- a plateau stands flush with the last face below it. Walkable ground met +-- on the way -- a path cut up the mountain -- resets the walk to its own +-- height, so a path is never buried. Profile-gated (`mountain_terraces`): +-- the same primary rock tiles run over half of Hoenn's routes, whose +-- terraces the elevation field already builds. +local TERRACE_TOP = { [108] = true, [109] = true, [113] = true, [190] = true } +-- A band of face drawn more than three cells deep is not a taller cliff but +-- a mountain FLANK seen side-on -- east of Ever Grande's Centre the face art +-- runs twenty rows unbroken -- and climbing every row stood it 192px, a wall +-- that buried the path beside it. A band climbs at most three courses and +-- the massif at most eight. +local TERRACE_BAND = 3 +local TERRACE_MAX = 8 * COURSE + +function Structures.terraceGen3Mountains(S, map, x0, x1, y0, y1) + if not S.isGen3 or not S.outdoor then return end + local okG, g3c = pcall(Gen3.forMap, map) + if not (okG and g3c and g3c.profile and g3c.profile.mountain_terraces) then return end + local rockIds = g3c.profile.mountain_rock + if type(rockIds) ~= "table" then return end + -- the plateau tops: a profile may name its own (Route 114's tan rock is + -- Fallarbor's, not the primary's), and only a profile that says so lets a + -- walkable top be re-levelled -- elsewhere a walkable plateau already + -- stands where the elevation field put it + local tops = type(g3c.profile.mountain_tops) == "table" + and g3c.profile.mountain_tops or TERRACE_TOP + local walkTops = g3c.profile.mountain_walkable_tops == true + local W = math.floor(tonumber(map.def.width) or 0) + local H = math.floor(tonumber(map.def.height) or 0) + if W <= 0 or H <= 0 then return end + local cx0 = math.max(0, math.floor(x0 / 2)) + local cx1 = math.min(W - 1, math.floor(x1 / 2)) + local cy0 = math.max(0, math.floor(y0 / 2)) + local cy1 = math.min(H - 1, math.floor(y1 / 2)) + local function K(cx, cy) return cy * 8192 + cx end + -- A WALKABLE CELL DRAWN AS PLATEAU IS PLATEAU. Ever Grande leaves a few + -- corner cells of rock (190) passable at elevation 0 -- (15,33), (17,34), + -- (22,35) -- inside the massif, unreachable from any path. Left at the + -- ground's height they were pits in the terrace; drawn as its top, they + -- are its top. + local function rockAt(cx, cy) + local okM, m = pcall(g3c.metatileAt, cx, cy) + if not (okM and m and rockIds[m]) then return nil end + local okW, wk = pcall(map.isWalkableCell, map, cx, cy) + if okW and wk and not (walkTops and tops[m]) then return nil end + local okB, isB = pcall(g3c.isBuildingCell, cx, cy) + if okB and isB then return nil end + local k = keyOf(cx * 2, cy * 2) + local sh = S.shapeAt[k] + if not sh or sh.override or S.skip[k] then return nil end + return tops[m] and "top" or "face" + end + local floorNear + local function groundAt(cx, cy) + local z = S.synthZ and S.synthZ[K(cx, cy)] + if z then return z end + local sh = S.shapeAt[keyOf(cx * 2, cy * 2 + 1)] + return sh and tonumber(sh.h) or 0 + end + + -- A FLANK IS NOT A STAIR. A face band longer than TERRACE_BAND rows is + -- a cliff's SIDE drawn as it runs north-south past the plateau (the long + -- vertical stripes of Routes 112 and 115), not courses stacked one over + -- another. Climbing it stood a wall three courses over both plateaus it + -- divides, and walled the paths in. Its rows are deferred and take the + -- height of the terrain either side of them instead -- the higher one, + -- since the flank is that plateau's edge. + local floorMemo = {} + function floorNear(cx, cy) + local k = K(cx, cy) + local m = floorMemo[k] + if m ~= nil then return m or nil end + local lo = nil + for r = 1, 4 do + for dy = -r, r do + for dx = -r, r do + if math.max(math.abs(dx), math.abs(dy)) == r then + local okW, wk = pcall(map.isWalkableCell, map, cx + dx, cy + dy) + if okW and wk and not rockAt(cx + dx, cy + dy) then + local z = groundAt(cx + dx, cy + dy) + if lo == nil or z < lo then lo = z end + end + end + end + end + if lo then break end + end + floorMemo[k] = lo or false + return lo + end + local flank = {} + local set, cells, hi = {}, 0, 0 + for cx = cx0, cx1 do + Budget.tick() + local runLen, cy = {}, H - 1 + while cy >= 0 do + if rockAt(cx, cy) == "face" then + local y = cy + while y >= 0 and rockAt(cx, y) == "face" do y = y - 1 end + for yy = y + 1, cy do runLen[yy] = cy - y end + cy = y + else + cy = cy - 1 + end + end + local h, band = nil, 0 + for cy = H - 1, 0, -1 do + local role = rockAt(cx, cy) + if role == "face" and (runLen[cy] or 0) > TERRACE_BAND then + role = "flank" + end + if role ~= "face" then band = 0 end + if role == nil then + local okW, wk = pcall(map.isWalkableCell, map, cx, cy) + -- walkable ground (or anything that is not this rock) is the datum + -- the next band of rock climbs from + h = (okW and wk) and groundAt(cx, cy) or nil + -- a cave mouth is a gap in a FACE: the rock either side climbs a + -- course here, and so must the column above the gap, or the mouth + -- leaves a slot a course deep all the way up the massif + if h and rockAt(cx - 1, cy) == "face" and rockAt(cx + 1, cy) == "face" then + h = h + COURSE + end + else + if h == nil then + -- rock running in from the map's south edge, off a building or a + -- prop: the lowest walkable ground near it is the datum, never the + -- world floor -- Route 115's terraces sit a course up, and a walk + -- started at 0 dug holes clean through them to the sky + h = floorNear(cx, cy) or 0 + end + if role == "face" then + band = band + 1 + if band <= TERRACE_BAND then h = math.min(TERRACE_MAX, h + COURSE) end + end + if role == "flank" then flank[K(cx, cy)] = h end + if cy >= cy0 and cy <= cy1 then set[K(cx, cy)] = h end + end + end + end + + -- ...the flanks, levelled to the terrain beside them + for k, z0 in pairs(flank) do + local cy = math.floor(k / 8192) + local cx = k - cy * 8192 + local best = nil + for _, dir in ipairs({ -1, 1 }) do + for n = 1, 4 do + local nk = K(cx + dir * n, cy) + if not flank[nk] then + local z = set[nk] + if z == nil then + local okW, wk = pcall(map.isWalkableCell, map, cx + dir * n, cy) + if okW and wk then z = groundAt(cx + dir * n, cy) end + end + if z and (best == nil or z > best) then best = z end + break + end + end + end + if best and set[k] ~= nil then set[k] = best end + end + -- ...AND A WAY INTO THE ROCK IS GROUND. A cave mouth is a walkable cell + -- cut into the face, and nothing about it is rock to the art readers, so + -- the volume pass measured the whole column above it as one run -- the + -- Seafoam Islands' west mouth stood 72px, a dark shaft up the face of the + -- island. A walkable cell against the terraced rock with a run of its own + -- stands on the ground in front of it. + local mouths = 0 + for k in pairs(set) do + local cy = math.floor(k / 8192) + local cx = k - cy * 8192 + for _, d in ipairs(DIRS4) do + local nx, ny = cx + d[1], cy + d[2] + local nk = K(nx, ny) + if not set[nk] and nx >= 0 and ny >= 0 and nx < W and ny < H then + local okW, wk = pcall(map.isWalkableCell, map, nx, ny) + local okB, isB = pcall(g3c.isBuildingCell, nx, ny) + if okW and wk and not (okB and isB) + and S.runs[keyOf(nx * 2, ny * 2 + 1)] then + local z = floorNear(nx, ny) or groundAt(nx, ny + 1) + for dy = 0, 1 do + for dx = 0, 1 do + local kk = keyOf(nx * 2 + dx, ny * 2 + dy) + local old = S.shapeAt[kk] + S.runs[kk] = nil + if old and not old.override then + local nu = {} + for k2, v2 in pairs(old) do nu[k2] = v2 end + nu.class, nu.flat, nu.h, nu.base = "ground", true, z, nil + S.shapeAt[kk] = nu + end + end + end + if S.synthZ then S.synthZ[nk] = z end + mouths = mouths + 1 + end + end + end + end + -- ...and nothing stands below the ground around it + for k, z in pairs(set) do + local cy = math.floor(k / 8192) + local cx = k - cy * 8192 + local f = floorNear(cx, cy) + if f and z < f then set[k] = f end + end + + for k, z in pairs(set) do + local cy = math.floor(k / 8192) + local cx = k - cy * 8192 + for dy = 0, 1 do + for dx = 0, 1 do + local kk = keyOf(cx * 2 + dx, cy * 2 + dy) + local old = S.shapeAt[kk] + if old and not old.override then + local nu = {} + for k2, v2 in pairs(old) do nu[k2] = v2 end + local okW, wk = pcall(map.isWalkableCell, map, cx, cy) + if okW and wk then + nu.class, nu.flat = "ground", true + else + nu.class, nu.flat, nu.art = "cliff", false, "upright" + end + nu.h = z + nu.face = nil + -- a column stands from the world floor: a base left over from the + -- run the cell used to belong to floated it, or -- above its new + -- top -- left nothing at all and a hole to the sky + nu.base = 0 + S.shapeAt[kk] = nu + S.runs[kk] = nil + end + end + end + if S.synthZ then S.synthZ[k] = z end + cells = cells + 1 + if z > hi then hi = z end + end + if cells > 0 then + local okL, Logger = pcall(require, "src.core.Logger") + if okL and Logger and Logger.info then + pcall(Logger.info, "gen3 shapes: %s terraced %d mountain cell(s), " + .. "highest %dpx", tostring(map.id), cells, hi) + end + end +end + function Structures.climbGen3Masses(S, map, x0, x1, y0, y1) if not S.isGen3 or not S.outdoor then return end local okG, g3c = pcall(Gen3.forMap, map) @@ -8361,12 +8840,12 @@ function Structures.climbGen3Masses(S, map, x0, x1, y0, y1) and not (S.rockWall and S.rockWall[nk]) then dist[nk] = dist[k] + 1 -- the highest rim one step nearer than this cell - local best = nil + local best, bestK, bestE = nil, nil, nil for _, e in ipairs(D) do local pk = K(nx + e[1], ny + e[2]) if dist[pk] == dist[nk] - 1 and want[pk] and (best == nil or want[pk] > best) then - best = want[pk] + best, bestK, bestE = want[pk], pk, e end end -- ...and it may spend no more courses than the body draws faces @@ -8507,6 +8986,63 @@ function Structures.climbGen3Masses(S, map, x0, x1, y0, y1) end -- ---- deliver, raise only + -- ...AND ROCK SOUTH OF OPEN GROUND STAYS LOW ENOUGH TO SEE OVER. + -- + -- The diorama is seen from the south, so a mass lying south of the + -- ground it rims climbs toward the lens. Route 25's ridge rose to 160px + -- over ten rows south of its path and Fuchsia's rock below the south fence + -- to 96, each a wall in front of the camera where the art draws a low + -- rocky bank. Down each column: a mass whose north neighbour is walkable + -- ground stands at most SOUTH_BANK courses above that ground -- unless + -- the walkable ground at its own south end is higher, which the rock is + -- the face of and must still reach. + do + local SOUTH_BANK = 2 + local function walkable(x, y) + if x < 0 or y < 0 or x >= W or y >= H then return false end + local okW, w = pcall(map.isWalkableCell, map, x, y) + return okW and w + end + local function floorOf(x, y) + return (S.synthZ and S.synthZ[K(x, y)]) or finishedFloor(S, map, g3c, x, y) + end + for cx = cx0, cx1 do + local cy = cy0 + while cy <= cy1 do + -- the ground to the north may be a fence row or two away (Fuchsia's + -- south fence stands between the street and its rock bank) + local gy = nil + if mass[K(cx, cy)] and cy > 0 and not mass[K(cx, cy - 1)] then + for up = 1, 3 do + local yy = cy - up + if yy < 0 or mass[K(cx, yy)] then break end + if walkable(cx, yy) then gy = yy break end + end + end + if gy then + local g = floorOf(cx, gy) + local y1 = cy + while y1 + 1 <= cy1 and mass[K(cx, y1 + 1)] do y1 = y1 + 1 end + if g then + local limit = g + SOUTH_BANK * COURSE + local south = walkable(cx, y1 + 1) and floorOf(cx, y1 + 1) or nil + if south and south > limit then limit = south end + for y = cy, y1 do + local k = K(cx, y) + if want[k] and want[k] > limit then + local was = zAt(cx, y) + want[k] = (was > limit) and was or limit + end + end + end + cy = y1 + 1 + else + cy = cy + 1 + end + end + end + end + local raised, hi = 0, 0 for k, z in pairs(want) do local cy = math.floor(k / 8192) @@ -8571,6 +9107,7 @@ end -- behind it may be a bank. function Structures.standGen3Ledges(S, map, x0, x1, y0, y1) if not S.isGen3 or not S.outdoor then return end + if not Structures.ledgeTerraces() then return end if type(Gen3.ledgeHopAt) ~= "function" then return end local okG, g3c = pcall(Gen3.forMap, map) if not (okG and g3c) then return end @@ -12459,9 +12996,26 @@ function Structures.forMap(map) -- Celadon Mansion is the case that found it: all four of its -- staircases are door tiles, so `stair_e` / `stair_down_w` pins there -- silently did nothing and the flights stayed painted on the floor. + -- A CAVE MOUTH IS AN OPENING, NOT A DOOR IN A WALL. Folded as one, the + -- Seafoam Islands' mouths each became a door column the height of the + -- whole map -- a dark shaft standing up the face of the island. Gen 3 + -- names where every warp leads; one into a cave keeps its own shape. + local caveDoor = {} + do + local okM, gm = pcall(V.data, "gen3_maps") + local all = okM and type(gm) == "table" and gm.maps or nil + if all then + for _, w in ipairs((def and def.warps) or {}) do + local dest = all[tostring(w.destMap)] + if dest and dest.kind == "cave" and tonumber(w.x) and tonumber(w.y) then + caveDoor[tonumber(w.y) * 8192 + tonumber(w.x)] = true + end + end + end + end for cy = math.floor(y0 / 2), math.floor(y1 / 2) do for cx = math.floor(x0 / 2), math.floor(x1 / 2) do - if map.doorTiles[map:cellTile(cx, cy)] then + if map.doorTiles[map:cellTile(cx, cy)] and not caveDoor[cy * 8192 + cx] then local northK = keyOf(cx * 2, cy * 2 - 1) local ns = shapeAt[northK] if ns and ns.art == "upright" then @@ -12891,6 +13445,8 @@ function Structures.forMap(map) -- ...and then the inside of a mountain, which the drop pass cannot -- measure because there is no floor beside it. Structures.climbGen3Masses(S, map, x0, x1, y0, y1) + -- ...and a massif the profile says is drawn as terraces, stepped by its art. + Structures.terraceGen3Mountains(S, map, x0, x1, y0, y1) -- ...and the lips, which are one surface with the terrace behind them. Structures.standGen3Ledges(S, map, x0, x1, y0, y1) -- ...and the falls, last, so nothing downstream boxes the cascade flat. @@ -13319,10 +13875,20 @@ function Structures.forMap(map) -- ---- tall grass: two standing tuft rows per tile. BODY only: the 2D -- renderer never draws a neighbour's ring, and standing scenery past a -- map's edge would poke into the map next door ---- - Structures.buildGrass(S, map, 0, tw - 1, 0, th - 1, data) - -- ---- flowers: the animated meadow tile stands as a 1px cutout ---- - Structures.buildFlowers(S, map, tw, th, x0, x1, y0, y1, data) + -- + -- ...AND ON GEN 3, ONCE THE GROUND HAS STOPPED MOVING. Both read the + -- height of the cell they stand on when they are built, and the Gen 3 + -- floor, ledge and terrace passes below raise that ground afterwards -- + -- so a flower bed or a grass mat on a raised ledge was laid at the old + -- level and sank into the terrace. Neither writes anything another + -- pass reads (`S.grassQuads` / `S.flowerQuads` only), so they simply run + -- last; Gen 1/2 has no such passes and keeps its order. + local function plants() + Structures.buildGrass(S, map, 0, tw - 1, 0, th - 1, data) + Structures.buildFlowers(S, map, tw, th, x0, x1, y0, y1, data) + end + if S.isGen3 then S._plantsAfterGround = plants else plants() end end @@ -13479,6 +14045,63 @@ function Structures.forMap(map) -- before they can be cut out of it. pcall(Structures.carveGen3DeckPlanks, S, map) end + -- ...AND A BUILDING STANDS ON ITS DOORSTEP AS THE GROUND FINALLY IS. + -- + -- `foundGen3Buildings` reads the doorstep before the floor and terrace + -- passes above have run, and those can raise it: Cape Brink's lawn came + -- up to 32 after its house was founded at 0, and the house sank into the + -- lawn to its eaves. Read the step again now, and lift (never lower) the + -- building's columns onto it. + if S.isGen3 and S.outdoor then + local okB, bd = pcall(Gen3.buildingsOf, map) + local okG, g3l = pcall(Gen3.forMap, map) + if okB and bd and bd.list and okG and g3l then + local lifted = 0 + for _, bl in ipairs(bd.list) do + local d = bl.warp or bl.door + local step = nil + if d then + local okW, wk = pcall(map.isWalkableCell, map, d[1], d[2] + 1) + if okW and wk then step = finishedFloor(S, map, g3l, d[1], d[2] + 1) end + end + if step then + local seen = {} + for _, c in ipairs(bl.cells) do + for dy = 0, 1 do + for dx = 0, 1 do + local run = S.runs[keyOf(c[1] * 2 + dx, c[2] * 2 + dy)] + if run and not seen[run] and not run.face then + seen[run] = true + local base = run.base or 0 + if step > base then + local dz = step - base + run.base = step + run.h = (run.h or 0) + dz + run.peak = (run.peak or run.h) + dz + lifted = lifted + 1 + end + end + end + end + end + end + end + if lifted > 0 then + local okL, Logger = pcall(require, "src.core.Logger") + if okL and Logger then + pcall(Logger.info, "gen3 shapes: %s lifted %d building column(s) onto the doorstep the ground settled at", tostring(map.id), lifted) + end + end + end + end + if S._plantsAfterGround then + local okPl, errPl = pcall(S._plantsAfterGround) + S._plantsAfterGround = nil + if not okPl then + local okL, Logger = pcall(require, "src.core.Logger") + if okL and Logger then pcall(Logger.warn, "gen3 shapes: %s plants failed: %s", tostring(map.id), tostring(errPl)) end + end + end -- A BUILDING'S BOX COVERS ITS WHOLE FOOTPRINT, AND THIS IS THE LAST WORD. -- @@ -13859,6 +14482,7 @@ function Structures.forMap(map) end end Structures.buildGen3Overhead(S, map, x0, x1, y0, y1) + Structures.buildGen3Arches(S, map, x0, x1, y0, y1) end V.require("EmeraldBuildings").settle(S) @@ -21316,8 +21940,28 @@ end -- underneath. The ground under the deck is repainted with the plaza's own -- paving, so you can walk beneath the awning and the wares tables keep -- their place in the shade. -local STALL_DECK = 20 -- px: the canopy's underside clears a walker -local STALL_THICK = 2 +-- THE CANOPY IS A HIP ROOF, AND THE DRAWING SAYS WHERE ITS EDGES ARE. +-- +-- Each stall is two rows of awning cells. Read off the art (517..551 over +-- 565..559, and the narrow 549..519 over 557..567): the canopy's outline +-- runs 8px into the two edge columns -- their outer halves are pavement -- +-- and from the back row's first pixel row down to the front row's ninth, +-- where a four-row scalloped VALANCE hangs over the pole tops. On top it +-- is a hip roof seen from above: a light ridge stripe across the back row, +-- a lit west hip, a shaded east hip and the long south slope. +-- +-- Built as one flat slab at a fixed height, with the back row's art +-- repeated over the front row and the whole front cell folded down as a +-- 16px wall, it read as a thick blue box. Now the outline is the eave, the +-- roof pitches to the ridge the art draws, only the valance hangs, and the +-- eave height is measured off the pavement beside the stall. +local STALL_EAVE = 20 -- px over the pavement: the valance's top +local STALL_RISE = 10 -- px: the ridge over the eave +local STALL_INSET = 8 -- px of pavement in each edge column +local STALL_TOP = 1 -- the back row's outline row +local STALL_VALANCE = 9 -- the front row's first valance row +local STALL_VALANCE_ROWS = 4 +local STALL_HIP = 12 -- px: the hips' run, as drawn local STALL_SHADE = { top = 1.0, fascia = 0.92, side = 0.78, under = 0.55 } function Structures.buildGen3Stalls(S, map, data, x0, x1, y0, y1) @@ -21339,109 +21983,167 @@ function Structures.buildGen3Stalls(S, map, data, x0, x1, y0, y1) return okB and m and awn[m] or false end - local function uvOf(tile) - local u0 = ((tile % perRow) * 8) / atlasW - local v0 = (math.floor(tile / perRow) * 8) / atlasH - return u0, v0, u0 + 8 / atlasW, v0 + 8 / atlasH + -- a texel of the drawing at world pixel (x, z), as one quad's uv rect + -- spanning `n` texels east from it (never across an 8px tile) + local function texRect(x, z, n) + local tile = S.tileAt[keyOf(math.floor(x / 8), math.floor(z / 8))] + if not tile then return nil end + local tx0 = (tile % perRow) * 8 + (x % 8) + local ty0 = math.floor(tile / perRow) * 8 + (z % 8) + local u0, u1 = tx0 / atlasW, (tx0 + n) / atlasW + local v0, v1 = ty0 / atlasH, (ty0 + 1) / atlasH + return u0, v0, u1, v1 + end + local function push(c1, c2, c3, c4, u0, v0, u1, v1, shade) + quads[#quads + 1] = { c1, c2, c3, c4, + uv = { { u0, v0 }, { u1, v0 }, { u1, v1 }, { u0, v1 } }, + shade = shade, own = true } end - local built = 0 - for cy = math.max(0, math.floor(y0 / 2)), math.min(H - 1, math.floor(y1 / 2)) do - for cx = math.max(0, math.floor(x0 / 2)), math.min(W - 1, math.floor(x1 / 2)) do + -- one stall per connected group of awning cells + local seen, built = {}, 0 + for cy0 = math.max(0, math.floor(y0 / 2)), math.min(H - 1, math.floor(y1 / 2)) do + for cx0 = math.max(0, math.floor(x0 / 2)), math.min(W - 1, math.floor(x1 / 2)) do Budget.tick() - if isAwn(cx, cy) then - local k0 = keyOf(cx * 2, cy * 2) - if not S.skip[k0] then - -- the ground the stall shades: the commonest flat neighbour - local votes, floorTile, bestN = {}, nil, 0 + if isAwn(cx0, cy0) and not seen[cy0 * 8192 + cx0] + and not S.skip[keyOf(cx0 * 2, cy0 * 2)] then + local cells, stack = {}, { { cx0, cy0 } } + seen[cy0 * 8192 + cx0] = true + local minX, maxX, minY, maxY = cx0, cx0, cy0, cy0 + while #stack > 0 do + local c = table.remove(stack) + cells[#cells + 1] = c + minX, maxX = math.min(minX, c[1]), math.max(maxX, c[1]) + minY, maxY = math.min(minY, c[2]), math.max(maxY, c[2]) for _, d in ipairs(DIRS4) do - local nx, ny = cx + d[1], cy + d[2] + local nx, ny = c[1] + d[1], c[2] + d[2] + if isAwn(nx, ny) and not seen[ny * 8192 + nx] then + seen[ny * 8192 + nx] = true + stack[#stack + 1] = { nx, ny } + end + end + end + + -- the pavement it stands on: the commonest flat neighbour's tile, + -- and that neighbour's height + local votes, floorTile, bestN, floorH = {}, nil, 0, 0 + for _, c in ipairs(cells) do + for _, d in ipairs(DIRS4) do + local nx, ny = c[1] + d[1], c[2] + d[2] if not isAwn(nx, ny) then local nk = keyOf(nx * 2, ny * 2) local ns = S.shapeAt[nk] if ns and ns.flat and ns.class ~= "void" - and ns.class ~= "water" then + and ns.class ~= "water" and not S.runs[nk] then local nt = S.tileAt[nk] if nt then votes[nt] = (votes[nt] or 0) + 1 - if votes[nt] > bestN then floorTile, bestN = nt, votes[nt] end + if votes[nt] > bestN then + floorTile, bestN, floorH = nt, votes[nt], ns.h or 0 + end end end end end + end - local frontRow = not isAwn(cx, cy + 1) - local yT = STALL_DECK - local yB = STALL_DECK - STALL_THICK - for dy = 0, 1 do - for dx = 0, 1 do - local tx, ty = cx * 2 + dx, cy * 2 + dy - local tile = S.tileAt[keyOf(tx, ty)] - -- a front cell's TOP continues the canopy behind it; its own - -- art is spent on the fascia below - local topTile = tile - if frontRow then - topTile = S.tileAt[keyOf(tx, (cy - 1) * 2 + dy)] or tile + if maxY > minY and maxX > minX then + local X0 = minX * 16 + STALL_INSET + local X1 = maxX * 16 + 16 - STALL_INSET + local Z0 = minY * 16 + STALL_TOP + local Zv = maxY * 16 + STALL_VALANCE + local ridge = minY * 16 + 9 + local eave = floorH + STALL_EAVE + local function roofH(x, z) + local f = math.min((z + 0.5 - Z0) / (ridge - Z0), + (Zv - z - 0.5) / (Zv - ridge), + (x + 0.5 - X0) / STALL_HIP, + (X1 - x - 0.5) / STALL_HIP) + f = math.max(0, math.min(1, f)) + return eave + math.floor(STALL_RISE * f + 0.5) + end + local under = eave - 1 + + for z = Z0, Zv - 1 do + -- the roof, in runs of one height within one tile + local x = X0 + while x < X1 do + local h = roofH(x, z) + local n = 1 + while x + n < X1 and (x + n) % 8 ~= 0 and roofH(x + n, z) == h do + n = n + 1 end - if tile then - local x, z = tx * 8, ty * 8 - local u0, v0, u1, v1 = uvOf(topTile) - quads[#quads + 1] = { { x, yT, z }, { x + 8, yT, z }, - { x + 8, yT, z + 8 }, { x, yT, z + 8 }, - uv = { { u0, v0 }, { u1, v0 }, { u1, v1 }, { u0, v1 } }, - shade = STALL_SHADE.top, own = true } - quads[#quads + 1] = { { x, yB, z + 8 }, { x + 8, yB, z + 8 }, - { x + 8, yB, z }, { x, yB, z }, - uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } }, - shade = STALL_SHADE.under, own = true } + local u0, v0, u1, v1 = texRect(x, z, n) + if u0 then + push({ x, h, z }, { x + n, h, z }, { x + n, h, z + 1 }, + { x, h, z + 1 }, u0, v0, u1, v1, STALL_SHADE.top) + push({ x, under, z + 1 }, { x + n, under, z + 1 }, + { x + n, under, z }, { x, under, z }, u0, v0, u1, v1, + STALL_SHADE.under) end - end - end - -- the skirt: the front cell's own 16px of art, folded down - if frontRow then - for dx = 0, 1 do - local tx = cx * 2 + dx - local x = tx * 8 - local z1 = (cy * 2 + 2) * 8 - for band = 0, 1 do - local tile = S.tileAt[keyOf(tx, cy * 2 + band)] - if tile then - local u0, v0, u1, v1 = uvOf(tile) - local top = yB - band * 8 - local bot = yB - band * 8 - 8 - quads[#quads + 1] = { { x, bot, z1 }, { x + 8, bot, z1 }, - { x + 8, top, z1 }, { x, top, z1 }, - uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } }, - shade = STALL_SHADE.fascia, own = true } + x = x + n + end + -- the risers between steps, and the eave's own edge + for xx = X0, X1 - 1 do + local h = roofH(xx, z) + local u0, v0, u1, v1 = texRect(xx, z, 1) + if u0 then + local nz = (z > Z0) and roofH(xx, z - 1) or under + if nz < h then + push({ xx + 1, nz, z }, { xx, nz, z }, { xx, h, z }, + { xx + 1, h, z }, u0, v0, u1, v1, STALL_SHADE.side) + end + local wz = (xx > X0) and roofH(xx - 1, z) or under + if wz < h then + push({ xx, wz, z }, { xx, wz, z + 1 }, { xx, h, z + 1 }, + { xx, h, z }, u0, v0, u1, v1, STALL_SHADE.side) + end + local ez = (xx < X1 - 1) and roofH(xx + 1, z) or under + if ez < h then + push({ xx + 1, ez, z + 1 }, { xx + 1, ez, z }, + { xx + 1, h, z }, { xx + 1, h, z + 1 }, + u0, v0, u1, v1, STALL_SHADE.side) + end + local sz = (z < Zv - 1) and roofH(xx, z + 1) or under + if sz < h then + push({ xx, sz, z + 1 }, { xx + 1, sz, z + 1 }, + { xx + 1, h, z + 1 }, { xx, h, z + 1 }, + u0, v0, u1, v1, STALL_SHADE.fascia) end end end end - -- flank plates on open east/west edges, one texel deep - for _, side in ipairs({ { -1, 0 }, { 1, 0 } }) do - if not isAwn(cx + side[1], cy) then - local x = side[1] < 0 and cx * 16 or cx * 16 + 16 - local z0, z1 = cy * 16, cy * 16 + 16 - local tile = S.tileAt[k0] - if tile then - local u0, v0, u1, v1 = uvOf(tile) - quads[#quads + 1] = { { x, yB, z0 }, { x, yB, z1 }, - { x, yT, z1 }, { x, yT, z0 }, - uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } }, - shade = STALL_SHADE.side, own = true } + + -- the valance: its drawn rows hang from the eave, front and back + for r = 0, STALL_VALANCE_ROWS - 1 do + local z = Zv + r + local ya, yb = under - r - 1, under - r + local x = X0 + while x < X1 do + local n = math.min(8 - x % 8, X1 - x) + local u0, v0, u1, v1 = texRect(x, z, n) + if u0 then + push({ x, ya, Zv + 1 }, { x + n, ya, Zv + 1 }, + { x + n, yb, Zv + 1 }, { x, yb, Zv + 1 }, + u0, v0, u1, v1, STALL_SHADE.fascia) + push({ x + n, ya, Zv }, { x, ya, Zv }, { x, yb, Zv }, + { x + n, yb, Zv }, u0, v0, u1, v1, STALL_SHADE.under) end + x = x + n end end - for dy = 0, 1 do - for dx = 0, 1 do - local tk = keyOf(cx * 2 + dx, cy * 2 + dy) - S.skip[tk] = true - S.runs[tk] = nil - S.ground[tk] = floorTile or false + for _, c in ipairs(cells) do + for dy = 0, 1 do + for dx = 0, 1 do + local tk = keyOf(c[1] * 2 + dx, c[2] * 2 + dy) + S.skip[tk] = true + S.runs[tk] = nil + S.ground[tk] = floorTile or false + end end + built = built + 1 end - built = built + 1 end end end @@ -23447,6 +24149,166 @@ local function overheadFor(map) return out end +-- ---- arches: a beam drawn over the path, raised to the walls it joins ---- + +-- ROUTE 23'S BADGE GATES. Between each pair of gate walls an arch spans the +-- path, and the cartridge draws its beam on the ABOVE-PLAYER layer of two +-- walkable rows: the beam's top across the bottom of the back row (689..691) +-- and its front face, Poke Ball and all, across the top of the front row +-- (697..699). Walkable ground with the beam painted on it -- so the beam lay +-- on the path while the walls either side stood 80px. +-- +-- The profile names each beam metatile and the clean ground under it +-- (`arch_span`, {[meta] = under}). The beam's own pixels -- the overhead +-- mask, nothing else -- are stood up at the height of the walls in the same +-- row: the back row's as a flat top, the front row's folded down as the +-- face, the stall skirt's move. The cell itself is repainted with its +-- ground, so the path runs on under the arch. +local ARCH_THICK = 3 +local ARCH_SHADE = { top = 1.0, face = 0.9, side = 0.75, under = 0.5 } + +function Structures.buildGen3Arches(S, map, x0, x1, y0, y1) + if not S.isGen3 or not S.outdoor then return end + local okC, g3c = pcall(Gen3.forMap, map) + if not (okC and g3c and g3c.profile) then return end + local spec = g3c.profile.arch_span + if type(spec) ~= "table" or type(g3c.metatileAt) ~= "function" then return end + local okM, masks = pcall(Gen3.overheadMasks, map.tileset) + if not (okM and masks) then return end + local perRow, atlasW, atlasH = geomOf(map.tileset) + if not (perRow and atlasW and atlasH and atlasW > 0 and atlasH > 0) then + return + end + local quads = S.objectQuads + local function beamAt(cx, cy) + local ok, m = pcall(g3c.metatileAt, cx, cy) + return ok and m and spec[m] and m or nil + end + -- the walls this beam joins: the tallest run in its own row, either side + local function deckHeight(cx, cy) + local best = nil + for _, dir in ipairs({ -1, 1 }) do + for n = 1, 6 do + local nx = cx + dir * n + if not beamAt(nx, cy) then + local run = S.runs[keyOf(nx * 2, cy * 2 + 1)] + if run and run.h and (best == nil or run.h > best) then + best = run.h + end + break + end + end + end + return best + end + local function uvAt(m, lx, ly) + local t = m * 4 + math.floor(ly / 8) * 2 + math.floor(lx / 8) + return ((t % perRow) * 8 + (lx % 8) + 0.5) / atlasW, + (math.floor(t / perRow) * 8 + (ly % 8) + 0.5) / atlasH + end + local function quad(c1, c2, c3, c4, u, v, shade) + quads[#quads + 1] = { c1, c2, c3, c4, u = u, v = v, shade = shade } + end + + local built = 0 + for cy = math.floor(y0 / 2), math.floor(y1 / 2) do + for cx = math.floor(x0 / 2), math.floor(x1 / 2) do + Budget.tick() + local m = beamAt(cx, cy) + local mask = m and masks[m] + local H = mask and deckHeight(cx, cy) + if H then + -- the front row is the one with no beam south of it + local front = beamAt(cx, cy + 1) == nil and beamAt(cx, cy - 1) ~= nil + local function at(lx, ly) + return lx >= 0 and lx <= 15 and ly >= 0 and ly <= 15 + and mask[ly * 16 + lx] or false + end + for ly = 0, 15 do + for lx = 0, 15 do + if at(lx, ly) then + local u, v = uvAt(m, lx, ly) + local x = cx * 16 + lx + if front then + -- folded down: pixel row ly hangs ly px below the deck, a + -- slab ARCH_THICK deep standing on the row's north edge + local zf = cy * 16 + local zb = zf + ARCH_THICK + local ya, yb = H - ly - 1, H - ly + quad({ x, ya, zb }, { x + 1, ya, zb }, { x + 1, yb, zb }, + { x, yb, zb }, u, v, ARCH_SHADE.face) + quad({ x + 1, ya, zf }, { x, ya, zf }, { x, yb, zf }, + { x + 1, yb, zf }, u, v, ARCH_SHADE.side) + if not at(lx, ly + 1) then + quad({ x, ya, zb }, { x, ya, zf }, { x + 1, ya, zf }, + { x + 1, ya, zb }, u, v, ARCH_SHADE.under) + end + if not at(lx, ly - 1) then + quad({ x, yb, zf }, { x, yb, zb }, { x + 1, yb, zb }, + { x + 1, yb, zf }, u, v, ARCH_SHADE.top) + end + if not at(lx - 1, ly) then + quad({ x, ya, zf }, { x, ya, zb }, { x, yb, zb }, + { x, yb, zf }, u, v, ARCH_SHADE.side) + end + if not at(lx + 1, ly) then + quad({ x + 1, ya, zb }, { x + 1, ya, zf }, { x + 1, yb, zf }, + { x + 1, yb, zb }, u, v, ARCH_SHADE.side) + end + else + -- the beam's top, flat at the walls' height + local z = cy * 16 + ly + local yT, yB = H, H - ARCH_THICK + quad({ x, yT, z }, { x + 1, yT, z }, { x + 1, yT, z + 1 }, + { x, yT, z + 1 }, u, v, ARCH_SHADE.top) + quad({ x, yB, z + 1 }, { x + 1, yB, z + 1 }, { x + 1, yB, z }, + { x, yB, z }, u, v, ARCH_SHADE.under) + if not at(lx, ly - 1) then + quad({ x + 1, yB, z }, { x, yB, z }, { x, yT, z }, + { x + 1, yT, z }, u, v, ARCH_SHADE.side) + end + if not at(lx, ly + 1) and beamAt(cx, cy + 1) == nil then + quad({ x, yB, z + 1 }, { x + 1, yB, z + 1 }, + { x + 1, yT, z + 1 }, { x, yT, z + 1 }, u, v, + ARCH_SHADE.face) + end + if not at(lx - 1, ly) then + quad({ x, yB, z }, { x, yB, z + 1 }, { x, yT, z + 1 }, + { x, yT, z }, u, v, ARCH_SHADE.side) + end + if not at(lx + 1, ly) then + quad({ x + 1, yB, z + 1 }, { x + 1, yB, z }, { x + 1, yT, z }, + { x + 1, yT, z + 1 }, u, v, ARCH_SHADE.side) + end + end + end + end + end + -- ...and the path under it is the path + local floor = tonumber(spec[m]) + if floor then + for dy = 0, 1 do + for dx = 0, 1 do + local tk = keyOf(cx * 2 + dx, cy * 2 + dy) + local t = floor * 4 + dy * 2 + dx + S.tileAt[tk] = t + if S.skip[tk] then S.ground[tk] = t end + end + end + end + built = built + 1 + end + end + end + if built > 0 then + local okL, Logger = pcall(require, "src.core.Logger") + if okL and Logger and Logger.info then + pcall(Logger.info, "gen3 shapes: %s raised %d arch cell(s) to the walls " + .. "they join", tostring(map.id), built) + end + end +end + --- Every authored overhead figure on this map, stood on the object in front. function Structures.buildGen3Overhead(S, map, x0, x1, y0, y1) if not S.isGen3 then return end @@ -25552,6 +26414,17 @@ function Structures.buildStairs(S, map, x0, x1, y0, y1) -- blocked east and west, that is what makes it a notch -- so it says -- so, and only the gate below needs an answer. axis = axis or s.stairAxis + -- ...AND A RAMP THE ART DOES NOT BAND STILL HAS A DIRECTION. A muddy + -- or bumpy slope taken as a flight (Route 115's ramp at (28, 8..9)) + -- draws no tread lines, so the art gives no axis and the cell fell + -- to the bare east-west stair at the world floor: a slot in the + -- terrace with the sky showing through it. The flight itself knows + -- which way it climbs. + if not axis and S.isGen3 and not down then + local okF, _, _, fAxis = pcall(Structures.flightEnds, map, cx, cy) + if okF and fAxis == "z" then axis = "y" + elseif okF and fAxis == "x" then axis = "x" end + end if axis and not down then local z0f, z1f = Structures.flightEnds(map, cx, cy) local climb = (z0f and z1f) and math.abs(z1f - z0f) or COURSE @@ -26151,6 +27024,40 @@ end -- `tiles` is a list of {tx, ty} forming one region (or what is left of one -- after object extraction); runs are column-local, heights are measured -- per column and reconciled per region. +-- Destinations whose building has a flat rooftop in the cartridge's art. +-- Read off the cartridge's own 2D art, town by town: the gyms and the dojo, +-- Celadon's store, condominiums, hotel, restaurant and game corner, Silph +-- Co, the power plant, the trainer tower, Cinnabar's lab, and every route +-- gate and entrance hall. Pewter's Museum is NOT here -- it wears a pink +-- hipped roof -- and nor is any Pokemon Center or Mart. +Structures.FLAT_ROOF_DESTS = { + -- a gym whose interior is storeys enters on "_Gym_1F" (Lavaridge, + -- Sootopolis); without it Lavaridge's gym kept a pitch and folded its + -- rooftop down its front + "_Gym$", "_Gym_%dF$", "_Dojo$", "_DepartmentStore", "_Condominiums", "_GameCorner$", + "_Hotel$", "_Restaurant$", "^SilphCo", "^PowerPlant", + "^Route[%w_]*Entrance", "_SafariZone_Entrance$", "^UndergroundPath_", + "^TrainerTower", "_PokemonLab_Entrance$", + -- Lavender's houses: a flat purple panel over one cell of wall + "^LavenderTown_House", "^LavenderTown_VolunteerPokemonHouse", +} +-- Destinations drawn as a front elevation rather than a plan: their whole +-- drawing is facade, stood up a course per tile row. +Structures.STAND_UP_DESTS = { "^PokemonTower", "_DepartmentStore", "_Condominiums" } +function Structures.standUpDest(name) + for _, pat in ipairs(Structures.STAND_UP_DESTS) do + if tostring(name):find(pat) then return true end + end + return false +end + +function Structures.flatRoofDest(name) + for _, pat in ipairs(Structures.FLAT_ROOF_DESTS) do + if tostring(name):find(pat) then return true end + end + return false +end + function Structures.buildVolume(S, map, tiles) local cols = {} for _, c in ipairs(tiles) do @@ -26957,7 +27864,56 @@ function Structures.buildVolume(S, map, tiles) end regionFlatRoof = votes >= 2 end + -- ...AND A GYM IS FLAT, WHEREVER ITS ART IS DRAWN. + -- + -- Reported: "all gyms look glitched instead of having flat tops". The + -- layer test above sends Cerulean's, Vermilion's and Celadon's gyms to the + -- pitch, because the cartridge draws their rooftop field ABOVE the player + -- -- the same cut its own note says costs Pewter's Museum. The door says + -- what the building is with no reading of art at all: every Kanto gym's + -- door warps into a map named "_Gym". So a region holding a building + -- whose door leads to a flat-roofed destination votes flat outright. + -- + -- Decided PER BUILDING, not for the region: Celadon's Condominiums share + -- one connected mass with the Pokemon Center, and the Center keeps its + -- hipped roof while the Condominiums go flat beside it. + local bldFlat = {} + if S.isGen3 and S.outdoor then + for _, r in ipairs(runs) do + local id = r.run.gen3Bld + if id and bldFlat[id] == nil then + local dest = gen3BuildingDest(map, id) + bldFlat[id] = (dest and Structures.flatRoofDest(dest)) or false + end + end + end + local function runFlat(run) + return regionFlatRoof or (run.gen3Bld ~= nil and bldFlat[run.gen3Bld] == true) + end + -- ...AND A REGION THAT IS NOT ONE BUILDING LEVELS PER BUILDING. + -- + -- MOTIVATED BY SAFFRON CITY, where Silph Co, the Mart, the Fan Club and the + -- flat yellow apartment blocks between them are one connected mass of 138 + -- columns. Levelled as a region, every one of those blocks took Silph's + -- 144px and its 26 roof rows, and stood as a tower. A compact region IS a + -- building and keeps the region rule; otherwise the key is the building + -- the warps name, or -- for a block with no door -- the row its top + -- starts on, which is what one drawn block shares across its width. + local partRoofRows, partRoofH = {}, {} + local function partOf(run) + if regionCompact then return "region" end + return run.gen3Bld and ("b" .. tostring(run.gen3Bld)) or ("n" .. tostring(run.north)) + end + for _, r in ipairs(runs) do + local rr = r.run.gen3RoofRows + if rr and rr > 0 then + local k = partOf(r.run) + if rr > (partRoofRows[k] or 0) then partRoofRows[k] = rr end + local rh0 = r.run.unit * 8 + if rh0 > (partRoofH[k] or 0) then partRoofH[k] = rh0 end + end + end for _, r in ipairs(runs) do local rr = r.run.gen3RoofRows if rr and rr > 0 then @@ -27462,7 +28418,7 @@ function Structures.buildVolume(S, map, tiles) end if regionRoofRows > 0 then if run.gen3RoofRows then - run.gen3RoofRows = regionRoofRows + run.gen3RoofRows = partRoofRows[partOf(run)] or regionRoofRows elseif fabric and regionCompact and run.extent >= 4 and (roofedNorth[r.tx - 1] == run.north or roofedNorth[r.tx + 1] == run.north @@ -27506,7 +28462,8 @@ function Structures.buildVolume(S, map, tiles) and (not S.isGen3 or run.gen3RoofRows) then run.base = regionBaseMax end - if regionRoofH > h then h = math.min(regionRoofH, drawnCap) end + local levelH = partRoofH[partOf(run)] or regionRoofH + if levelH > h then h = math.min(levelH, drawnCap) end -- A ROOF SEEN FROM ABOVE IS DEPTH, NOT HEIGHT. -- -- This is the thing that made every civic building in Hoenn a tower. @@ -27754,8 +28711,9 @@ function Structures.buildVolume(S, map, tiles) -- Route 23's from 259 to 263, which is the run structure moving under a -- change that was meant to move a roof. `warpBuilt` is the region-level -- "a warp names a building here" the chain above already computes. - if regionFlatRoof - and (warpBuilt or run.gen3RoofRows or run.door == true) then + if (regionFlatRoof + and (warpBuilt or run.gen3RoofRows or run.door == true)) + or (run.gen3Bld ~= nil and bldFlat[run.gen3Bld] == true) then roofRows = 0 -- ...AND HOW DEEP THAT FLAT ROOFTOP IS, SO THE WALL KNOWS WHERE IT -- STOPS. @@ -27789,7 +28747,7 @@ function Structures.buildVolume(S, map, tiles) -- `g3-back-333` had to gate against -- and a cliff face is SUPPOSED -- to wear its own drawing top to bottom. Recorded for those, the -- depth truncated their faces: Route 124 and Route 133 both moved. - local fr, ty = 0, run.north + local fr, ty, trim = 0, run.north, false while (run.gen3Bld ~= nil or run.door == true) and ty + 1 <= (run.front or ty) and fr < GEN3_MAX_ROWS do local function rowTile(x) @@ -27801,10 +28759,23 @@ function Structures.buildVolume(S, map, tiles) while n < 24 and rowTile(x) == a do n = n + 1; x = x - 2 end x = r.tx + 2 while n < 24 and rowTile(x) == a do n = n + 1; x = x + 2 end - if n < FLAT_FIELD_DEPTH then break end - fr = fr + 2 - ty = ty + 2 + if n >= FLAT_FIELD_DEPTH then + fr = fr + 2 + ty = ty + 2 + elseif fr == 0 and not trim then + -- A TRIM COURSE FIRST, the same allowance the vote makes when it + -- tries the second cell row: Cerulean's Gym reads a cap row that + -- does not tile across, then its field. Walked from the top row + -- alone the depth came back 0, the wall stopped nowhere, and the + -- gym wore its rooftop down its front again. + trim = true + fr = 2 + ty = ty + 2 + else + break + end end + if trim and fr <= 2 then fr = 0 end -- a trim with no field is no roof if fr > 0 then run.gen3FlatRows = fr end end -- HOW MUCH OF THE DRAWING IS ROOF -- which is not how far the roof @@ -28046,6 +29017,15 @@ function Structures.buildVolume(S, map, tiles) h = 0 run.face = { rows = run.extent, dir = face.dir } end + -- ...AND A FLAT ROOFTOP IS NOT WALL. The rows the flat-roof depth walk + -- measured are drawn in plan, lying on top of the building; only what + -- is left below them is facade. Unclamped, Cerulean's Gym stood 64px + -- of facade over a 32px drawing and folded its window course up the + -- front four times. + if S.isGen3 and roofRows == 0 and (run.gen3FlatRows or 0) > 0 and not face then + local facade = math.max(2, (run.extent or 0) - run.gen3FlatRows) * 8 + if facade < h then h = facade end + end run.rise = roofRows * 8 -- ...measured from the run's own datum, so a house on a terrace stands -- ON it rather than in it. `base` is 0 everywhere the ground is the @@ -28057,6 +29037,103 @@ function Structures.buildVolume(S, map, tiles) end end + -- ONE FLAT ROOFTOP PER BUILDING, AT ONE HEIGHT. + -- + -- Cerulean's Gym measured its columns from three different tops (rows 34, + -- 36 and 40) and so stood them 16, 32 and 64px tall: a flat roof in three + -- steps, which is the "glitched gym" reported. The building is one + -- drawing: its rooftop starts at the highest row any column measured a + -- field on and is as deep as the deepest field; what is left down to the + -- front is facade, the same for every column. Each column then wears as + -- many rows of roof art as lie above that shared wall line. + if S.isGen3 then + local flatBld = {} + for _, r in ipairs(runs) do + local run = r.run + local id = run.gen3Bld + if id and (run.gen3FlatRows or 0) > 0 and runFlat(run) then + local b = flatBld[id] or { top = run.north, fr = 0, front = run.front } + b.top = math.min(b.top, run.north) + b.fr = math.max(b.fr, run.gen3FlatRows) + b.front = math.max(b.front, run.front) + flatBld[id] = b + end + end + -- ...AND THE FACADE STANDS ON THE MAIN FRONT, NOT THE ENTRANCE BAY'S. + -- + -- Every Emerald gym projects its doors one cell south of the rest of its + -- front (Petalburg's 458/461/459 under a 12-column block). Measured + -- from the deepest column, the whole gym stood a cell too tall, and the + -- columns with no art for that extra course folded their roof down the + -- front: the dark curtains hanging over every gym's facade. The main + -- front is the one most columns share; a column that runs past it is + -- cut there, and its bay stands as a low block of its own -- one course + -- per tile row it draws -- in front of the wall. + local fronts = {} + for _, r in ipairs(runs) do + local run = r.run + local id = run.gen3Bld + if id and flatBld[id] then + fronts[id] = fronts[id] or {} + fronts[id][run.front] = (fronts[id][run.front] or 0) + 1 + end + end + for id, b in pairs(flatBld) do + local best, bestN = b.front, -1 + for f, n in pairs(fronts[id] or {}) do + if n > bestN or (n == bestN and f < best) then best, bestN = f, n end + end + -- only a shallow bay: a deeper difference is a wing, not a porch + if best < b.front and b.front - best <= 2 then b.main = best end + end + local function standBay(run, tx, at) + local bay = {} + for k, v in pairs(run) do bay[k] = v end + bay.north, bay.extent = at, run.front - at + 1 + bay.rise, bay.roofRows, bay.gen3RoofRows = 0, nil, nil + bay.roofArtTop, bay.roofArtRows = nil, nil + bay.gen3FlatTop, bay.gen3FlatRows = nil, nil + bay.gen3WallArtFront, bay.gableExtent, bay.gen3ArtNorth = nil, nil, nil + bay.shedRoof = nil + bay.h = bay.extent * 8 + (run.base or 0) + bay.peak = bay.h + bay.gen3Bay = true + for ty = at, run.front do S.runs[keyOf(tx, ty)] = bay end + return bay + end + for _, r in ipairs(runs) do + local run = r.run + local b = run.gen3Bld and flatBld[run.gen3Bld] + if b and b.main and not run.face and run.front > b.main then + if run.north > b.main then + standBay(run, r.tx, run.north) + run.gen3BayOnly = true + else + standBay(run, r.tx, b.main + 1) + run.front = b.main + run.extent = run.front - run.north + 1 + end + end + end + for _, r in ipairs(runs) do + local run = r.run + local b = run.gen3Bld and flatBld[run.gen3Bld] + if b and not run.face and not run.gen3BayOnly then + local wallLine = b.top + b.fr + local facade = math.max(2, (b.main or b.front) - wallLine + 1) * 8 + run.rise = 0 + run.h = facade + run.base + run.peak = run.h + local art = wallLine - run.north + run.gen3FlatRows = art > 0 and art or nil + -- the TOP face's art: this column's own roof rows, or -- for an + -- entrance bay that starts at the wall line and has none -- the + -- building's field, so the bay's top is roof and not its own sign + run.gen3FlatTop = art > 0 and { run.north, art } or { b.top, b.fr } + end + end + end + -- ONE ROOF SURFACE PER BUILDING, not one per column. -- -- The eave, the rise and the foundation are all levelled above, and the @@ -30042,7 +31119,11 @@ function Structures.buildGen3GrassMats(S, map, x0, x1, y0, y1) end function Structures.buildGen3Grass(S,map,x0,x1,y0,y1,data) - if require("src.core.GameVersion").get()~="emerald" then + -- Kanto's tall grass stands as sprite cards too -- reported: "the grass + -- should be rendering as sprites" -- the same cut-to-the-drawing cards + -- Hoenn's wear. Anything else Gen 3 keeps the mats. + local ver=require("src.core.GameVersion").get() + if ver~="emerald" and ver~="firered" and ver~="leafgreen" then return Structures.buildGen3GrassMats(S,map,x0,x1,y0,y1) end local kinds=Gen3.spec().grass_kind diff --git a/mods/DRAMATIC_SHAPE/lib/VoxelDiskCache.lua b/mods/DRAMATIC_SHAPE/lib/VoxelDiskCache.lua index 2ca19368c..dbf6a2138 100644 --- a/mods/DRAMATIC_SHAPE/lib/VoxelDiskCache.lua +++ b/mods/DRAMATIC_SHAPE/lib/VoxelDiskCache.lua @@ -259,7 +259,9 @@ end local function shapeSignature() local ok, S = pcall(V.require, "Structures") if ok and type(S) == "table" and S.SHAPE_REV then - return tostring(S.SHAPE_REV) + local ledges = (type(S.ledgeTerraces) == "function" and S.ledgeTerraces()) + and "+ledges" or "" + return tostring(S.SHAPE_REV) .. ledges end return "?" end diff --git a/mods/DRAMATIC_SHAPE/main.lua b/mods/DRAMATIC_SHAPE/main.lua index 7eee8f98c..ff74f2fac 100644 --- a/mods/DRAMATIC_SHAPE/main.lua +++ b/mods/DRAMATIC_SHAPE/main.lua @@ -221,6 +221,12 @@ local VoxelScene = V.require("VoxelScene") local NativeGen4 = V.require('NativeGen4') local TiltShift = V.require("TiltShift") local ChunkMesher = V.require("ChunkMesher") +-- LEDGE TERRACES moves the ground, so every built mesh goes: the disk cache +-- already keys on it (VoxelDiskCache.shapeSignature), this drops the copies +-- in memory and the queue, and the world rebuilds around the player. +V.require("Structures").ledgeSetting.onChange = function() + ChunkMesher.invalidate() +end if require('src.core.GameVersion').get() == 'polishedcrystal' then ChunkMesher.setCacheRulesTag('emerald-clean-civic-roofs-v16') end @@ -600,6 +606,17 @@ end local SETTINGS = { { VoxelGrid.setting, "One-pixel wireframe along every voxel edge." }, + -- Geometry, not look: the FULL preset does not own it, so `full = true` + -- keeps it on the menu there too. A change drops every built mesh (see + -- rebuildForLedges below) because the ground itself moves. + { V.require("Structures").ledgeSetting, + "Gen 3 hop-down ledges raise the ground behind them into terraces. OFF " + .. "keeps the map's own ground level and gives each ledge a low lip.", + when = function() + local ok, GV = pcall(require, "src.core.GameVersion") + return ok and GV.generation and GV.generation(GV.get()) == 3 + end, + full = true }, { WorldCurve.setting, "Bend the world down over the horizon, Animal Crossing style." }, { Water.setting, diff --git a/scripts/build.sh b/scripts/build.sh index 08826685a..7e9fd012d 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -90,7 +90,7 @@ say "manifests:$MANIFESTS" # mods/ ships too: the launcher's MODS tab lists what is installed, and the # bundled example mods (DramaticShapes among them) live there. (cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \ - main.lua conf.lua src data assets mods tools/save-editor \ + main.lua conf.lua src data assets mods tools/save-editor tools/map-editor \ $MANIFESTS \ -x '*.DS_Store' 'data/generated/*' 'assets/generated/*') if unzip -Z1 "$LOVE_FILE" \ @@ -102,8 +102,13 @@ fi # ROM import fails in the built app (dev reads them off the source tree, so # the miss only ever shows up in a build -- the Yellow manifest shipped this # way once). +# tools/map-editor too: the editor's MAP pages require it pcall'd, so a build +# without it opened a MAP EDITOR with an empty body for every game -- +# scripts/pack_love.sh shipped it, this list did not. for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \ - tools/save-editor/panels/Party.lua; do + tools/save-editor/panels/Party.lua \ + tools/map-editor/MapEdits.lua tools/map-editor/ModExport.lua \ + tools/map-editor/panels/Preview.lua; do unzip -Z1 "$LOVE_FILE" | grep -qx "$required" \ || fail "game.love is missing $required" done diff --git a/scripts/build_linux_arm64.sh b/scripts/build_linux_arm64.sh index 12e32ead8..4f774ea3b 100755 --- a/scripts/build_linux_arm64.sh +++ b/scripts/build_linux_arm64.sh @@ -99,6 +99,17 @@ then cp "$ICON_SRC" "$IN_DIR/icon.png" fi cp "$GAME_LOVE" "$IN_DIR/game.love" +# The offline translator, when one for this architecture was built: the game +# looks for it at /translation/rom-translate. +rm -rf "$IN_DIR/translation" +if [ -f "$ROOT/translation/rom-translate" ]; then + if file -b "$ROOT/translation/rom-translate" | grep -q "aarch64"; then + mkdir -p "$IN_DIR/translation" + cp "$ROOT/translation/rom-translate" "$IN_DIR/translation/" + else + warn "translation/rom-translate is not an aarch64 binary; not packing it" + fi +fi # --------------------------------------------------------------- downloads # Fetched on the host and checksum-pinned here so the container never needs diff --git a/scripts/linux-arm64/build_appimage.sh b/scripts/linux-arm64/build_appimage.sh index 79b62066b..df9e23337 100755 --- a/scripts/linux-arm64/build_appimage.sh +++ b/scripts/linux-arm64/build_appimage.sh @@ -5,7 +5,7 @@ # # Mounts the host provides: # /cache pinned downloads + the compiled LÖVE prefix (persists between runs) -# /in read-only inputs: game.love, icon.png +# /in read-only inputs: game.love, icon.png, translation/ (optional) # /out the finished AppImage lands here # # Environment: @@ -357,6 +357,11 @@ cp -R "$jit_share/jit" "$APPDIR/share/$LUAJIT_SHARE_DIR/" # --------------------------------------------------------------- branding cp "$IN/game.love" "$APPDIR/game.love" +if [ -f "$IN/translation/rom-translate" ]; then + mkdir -p "$APPDIR/translation" + cp "$IN/translation/rom-translate" "$APPDIR/translation/" + chmod 0755 "$APPDIR/translation/rom-translate" +fi unzip -Z1 "$APPDIR/game.love" > "$WORK/love-listing.txt" grep -qxF "tools/rom_manifest_gold.json" "$WORK/love-listing.txt" \ || fail "game.love is missing tools/rom_manifest_gold.json" diff --git a/src/audio/NitroAudio.lua b/src/audio/NitroAudio.lua index d055c5650..fdd15adb2 100644 --- a/src/audio/NitroAudio.lua +++ b/src/audio/NitroAudio.lua @@ -8,13 +8,27 @@ local function u8(s,o) return s:byte(o+1) end local function u16(s,o) local a,b=s:byte(o+1,o+2);return b and a+b*256 end local function u32(s,o) local a,b,c,d=s:byte(o+1,o+4);return d and a+b*256+c*65536+d*16777216 end local function track(at) return {at=at,wait=0,program=0,volume=127,expression=127,pan=64,transpose=0,bend=0,bendRange=2,noteWait=true,calls={},loops={}} end -function Audio.new(data,def) - local path=assert(data.audio.ndsArchive,'missing imported SDAT') +-- The imported SDAT, opened once per path and shared by every player (BGM and +-- each sound effect alike). +function Audio.archive(data) + local path=assert(data and data.audio and data.audio.ndsArchive,'missing imported SDAT') local arc=archives[path] if not arc then - local bytes=assert(love.filesystem.read(require('src.render.Assets').resolve(path))) - arc=assert(Sdat.open(bytes));archives[path]=arc + local resolved=require('src.render.Assets').resolve(path) + local bytes,why=love.filesystem.read(resolved) + -- The game mounts the version cache over the read path; a tool run with + -- POKEPORT_ASSET_ROOT has no mount and reads the cache folder directly. + local root=not bytes and os.getenv('POKEPORT_ASSET_ROOT') + if root then + local f=io.open(root..'/'..resolved,'rb') + if f then bytes=f:read('*a');f:close() end + end + arc=assert(Sdat.open(assert(bytes,why)));archives[path]=arc end + return arc +end +function Audio.new(data,def) + local arc=Audio.archive(data) local seq=assert(Sdat.sequence(arc,assert(def.nds)),'missing SDAT sequence') local at,size=Sdat.fileAt(arc,seq.file);local bytes=arc.data:sub(at+1,at+size) assert(bytes:sub(1,4)=='SSEQ','not a native SSEQ') @@ -26,6 +40,9 @@ function Audio.new(data,def) return self end function Player:setVolume(v) self.queue:setVolume(v) end +-- A playback-rate factor over every voice (sound-effect pitch shifts). +function Player:setPitch(v) self.pitch=tonumber(v) or 1 end +function Player:getPitch() return self.pitch or 1 end function Player:getVolume() return self.queue:getVolume() end function Player:setFilter(...) return self.queue:setFilter(...) end function Player:setLooping(v) self.looping=v end @@ -139,7 +156,7 @@ function Player:render() local index=math.floor(v.pos)+1;local gain=v.gain if v.release>0 then gain=gain*v.release/(RATE*0.04);v.release=v.release-1;if v.release==0 then table.remove(self.voices,n) end end local value=(pcm[index] or 0)*gain - left=left+value*v.left;right=right+value*v.right;v.pos=v.pos+v.step + left=left+value*v.left;right=right+value*v.right;v.pos=v.pos+v.step*(self.pitch or 1) end end sound:setSample(i,1,math.max(-1,math.min(1,left))) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 15ddc7465..772548f53 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -486,6 +486,63 @@ function BattleState:worldBackdrop() return live end +-- IS A MOD FIGHTING THIS BATTLE ON THE MAP ITSELF? +-- +-- Reported from play: with BATTLE BG "world", DramaticShapes' map battles +-- (and absol89's BATTLE_ART_VOXEL_GEN2 fork) went "almost everything black -- +-- only the HUD is visible". The world IS their battle: the arena, the +-- billboards and the Pokemon are drawn in the world pass, so the +-- BG_WORLD_DIM wash meant for a frozen map behind a paper field landed on the +-- whole fight, and at night it left nothing but the boxes. +-- +-- Two ways a mod says so, neither of which is `inWorld3DBattleStatus` above: +-- * `exports.battleStage.state(battle)` (Battle Art's descriptor): a table +-- with `staged` and `surfaceOwned` for THIS battle; +-- * `exports.lib.require("OverworldBattle")`: DramaticShapes' own module, +-- whose `battle()` is this battle while it stages one. +-- Kept apart from worldBackdrop: that one also turns the windows to glass, +-- which these mods style for themselves. +function BattleState:worldStaged() + if self:worldBackdrop() then return true end + local frame = self.frame or 0 + local last = self._stagedPolled + if last ~= nil and (frame - last) < BattleState.BACKDROP_POLL_FRAMES then + return self._stagedLive == true + end + self._stagedPolled = frame + local live = false + pcall(function() + local mods = self.game and self.game.mods + local exports = mods and mods.exports + if type(exports) ~= "table" then return end + for _, mod in pairs(exports) do + if type(mod) == "table" then + local stage = mod.battleStage + if type(stage) == "table" and type(stage.state) == "function" then + local ok, st = pcall(stage.state, self) + if ok and type(st) == "table" and st.staged and st.surfaceOwned then + live = true + return + end + end + local lib = mod.lib + if type(lib) == "table" and type(lib.require) == "function" then + local okR, OB = pcall(lib.require, "OverworldBattle") + if okR and type(OB) == "table" and type(OB.battle) == "function" then + local okB, b = pcall(OB.battle) + if okB and b ~= nil and (b == self or (type(b) == "table" and b.battle == self)) then + live = true + return + end + end + end + end + end + end) + self._stagedLive = live + return live +end + -- The style this battle's windows wear, or nil for the Game Boy's solid -- white paper. Separate from worldBackdrop so a caller can override the -- look without re-deciding when it applies. @@ -4090,6 +4147,9 @@ function BattleState:touchpressed(_, px, py) local kind, index = Gen4B.bottomHit(x, y, over) local input = self.game and self.game.input if not input then return true end + -- a tap hides the keypad cursor; the overlay press below must not be + -- swallowed as the "show the cursor" press, so it is marked as touch + if kind then self.gen4CursorActive = false; self.gen4TouchPress = true end if kind == "cancel" then input:overlayPressed("b") input:overlayReleased("b") @@ -4152,6 +4212,27 @@ function BattleState:update(dt) end end + -- THE BOTTOM SCREEN'S CURSOR IS HIDDEN UNTIL A KEY IS PRESSED + -- (battle_subscreen.c BattleSystem_MenuKeys: an inactive cursor turns on at + -- the first A, B, X, Y or d-pad press, which does nothing else). A tap + -- turns it off again (touchpressed below), and the bag and the party list + -- share the same flag, as the cartridge carries isCursorEnabled across. + if self.gen4TouchPress then + self.gen4TouchPress = nil + elseif input and self:gen4Layout() and not self.gen4CursorActive + and (self.phase == "menu" or self.phase == "moveSelect" or self.phase == "targetSelect") then + local presentation = Gen4Battle.menuPresentation(self, self.phase) + if presentation == "bottom" then + for _, k in ipairs({ "a", "b", "x", "y", "up", "down", "left", "right" }) do + if input:wasPressed(k) then + self.gen4CursorActive = true + require("src.ui.Gen4SubMenuCursor").sound("cursor", self.data) + return + end + end + end + end + -- safety net: HP/status changed outside a queued drain (level-up heals, -- field effects, bag cures) snaps once the queue is idle if self.phase == "menu" then @@ -8865,10 +8946,55 @@ function BattleState:learnMove(mon, moveId) -- ordered insert so multi-level gains keep each level's checks -- between its own stat box and the next "grew to level" text self:uiNext(function() - return self:buildScreen("MoveLearnMenu", mon, moveId) + local screen = self:buildScreen("MoveLearnMenu", mon, moveId) + self:gen4LearnOnBottom(screen, mon, moveId) + return screen end) end +-- PLATINUM ASKS WHICH MOVE TO FORGET ON THE BOTTOM SCREEN: after "Delete an +-- older move?" YES the battle party list opens in BATTLE_PARTY_MODE_LEARN_MOVE +-- (the four moves, the one to learn, FORGET / CANCEL) instead of the field's +-- list. The question, the refusal and the "1, 2 and... Poof!" are still the +-- move-learn screen's; only the pick moves. Same rule as the party list: +-- the bottom screen up and the pages in the cache. +function BattleState:gen4LearnOnBottom(screen, mon, moveId) + if not (screen and type(screen.update) == "function" and screen.mon == mon) then return end + if Gen4Battle.partyScreenId(self) ~= "Gen4BattleParty" then return end + local okP, P = pcall(require, "src.ui.Gen4BattleParty") + if not (okP and P.pagesAvailable(self.data or self.game.data)) then return end + local battle = self + local plainUpdate, plainDraw = screen.update, screen.draw + screen.update = function(s, dt) + if s.selecting and not s.gen4Asked then + s.gen4Asked = true + battle.game.stack:push(P.new(battle.game, { + battle = battle, mode = "learnMove", mon = mon, moveId = moveId, + onChoose = function(index) + s.gen4Asked = false + if not index then + if s.confirmAbandon then s:confirmAbandon() end + return + end + local old = mon.moves[index] + local def = battle.data.moves[moveId] + mon.moves[index] = { id = moveId, pp = def and def.pp } + local oldDef = old and battle.data.moves[old.id] + s.forgot = (oldDef and oldDef.name) or (old and old.id) + s:finish(true) + end, + })) + return + end + if s.gen4Asked then return end + return plainUpdate(s, dt) + end + screen.draw = function(s, ...) + if s.selecting then return end + if plainDraw then return plainDraw(s, ...) end + end +end + -- Map the battle was fought on (overworld wins; save.player.map is fallback). function BattleState.currentMapId(self) local game = self.game @@ -9406,10 +9532,17 @@ function BattleState:openItems() return end self:ui(function() - return self:buildScreen("BagMenu", { battle = self }) + return self:buildScreen(Gen4Battle.bagScreenId(self) or "BagMenu", { battle = self }) end) end +-- Which screen picks an item's target in this battle: Platinum's battle party +-- list in its USE ON WHICH POKeMON? mode when it is the party screen here +-- (BagMenu's pickTargetAndUse asks), nil for the generation's party menu. +function BattleState:gen4ItemTargetScreen() + return Gen4Battle.partyScreenId(self) +end + -- called by BagMenu after an item is used in battle (consumes the turn) function BattleState:itemUsed(messages) -- bag cures clear mon.status before the message UI; refresh the HUD diff --git a/src/battle/Gen4Battle.lua b/src/battle/Gen4Battle.lua index 33049c370..06d305a84 100644 --- a/src/battle/Gen4Battle.lua +++ b/src/battle/Gen4Battle.lua @@ -554,7 +554,7 @@ function Gen4Battle.spriteYOffset(battle, battler) local data = (battle and (battle.data or (battle.game or {}).data)) or {} local rec = ((data.gen4_species_sprites or {}).species or {})[id] if type(rec) ~= "table" then return 0 end - local back = (battle and battler == battle.player) + local back = (battle and (battler == battle.player or battler.isPlayer == true)) local n = tonumber(back and rec.backOffset or rec.frontOffset) return n or 0 end @@ -766,15 +766,38 @@ function Gen4Battle.drawEnemyTrainer(battle) return one(battle.trainerPic, 1) end +-- WHO STANDS WHERE. A single battle is the solo pair, battler types 1 and 0. +-- A double puts BattleState position p (0 player left, 1 foe left, 2 player +-- right, 3 foe right) on battler type p + 2 -- the cartridge's own order, +-- PLAYER_SLOT_1, ENEMY_SLOT_1, PLAYER_SLOT_2, ENEMY_SLOT_2 -- and draws the +-- far side first: the foe's slot 2 (type 5, y 42), its slot 1 (type 3), then +-- the player's slot 1 (type 2) and slot 2 (type 4, y 120), nearest last. +function Gen4Battle.doubleSlots(battle) + return battle and battle.isDouble and battle:isDouble() and battle.battlerAt and true or false +end + +function Gen4Battle.battlerRows(battle) + if not Gen4Battle.doubleSlots(battle) then + return { + { battler = battle.enemy, pos = Gen4Battle.battlerPos(battle, 1), slot = 1, side = "enemy" }, + { battler = battle.player, pos = Gen4Battle.battlerPos(battle, 0), slot = 0, side = "player" }, + } + end + local rows = {} + for _, p in ipairs({ 3, 1, 0, 2 }) do + local b = battle:battlerAt(p) + rows[#rows + 1] = { battler = b, pos = Gen4Battle.battlerPos(battle, p + 2), slot = p + 2, + side = (p % 2 == 1) and "enemy" or "player" } + end + return rows +end + function Gen4Battle.drawBattlers(battle) local g = love.graphics g.setColor(1, 1, 1, 1) -- The foe first, so where the two overlap the player's Pokemon is in front -- -- which is what standing nearer the camera means. - local pairs_ = { - { battler = battle.enemy, pos = Gen4Battle.battlerPos(battle, 1) }, - { battler = battle.player, pos = Gen4Battle.battlerPos(battle, 0) }, - } + local pairs_ = Gen4Battle.battlerRows(battle) for _, row in ipairs(pairs_) do local battler = row.battler if battler and not battler.fainted @@ -1124,8 +1147,11 @@ function Gen4Battle.drawParticles(battle) if tileS > 1 or tileT > 1 then -- REPEAT WRAP IS SET ON THE IMAGE ITSELF, which is shared through the -- asset cache -- safe here because a particle texture is drawn from - -- nowhere else, and stated so the next caller knows. - img:setWrap("repeat", "repeat") + -- nowhere else, and stated so the next caller knows. A texture whose + -- SPLTextureParam sets FLIP mirrors every other copy: a quarter-circle + -- tiled 2x2 is a ring, not four notched squares. + local art = q.art or {} + img:setWrap(art.flipS and "mirroredrepeat" or "repeat", art.flipT and "mirroredrepeat" or "repeat") g.draw(img, tiledQuad(w, h, tileS, tileT), fx, fy, (tonumber(q.rotation) or 0)+turn, sx*perspective / tileS, sy*perspective / tileT, w * tileS / 2, h * tileT / 2) @@ -1193,6 +1219,7 @@ Gen4Battle.CLASSIC_DY = (192 - 144) / 2 Gen4Battle.HEALTHBOX_ART = { player = { key = "healthbox_player_singles", ox = -64, oy = -32 }, enemy = { key = "healthbox_enemy", ox = -64, oy = -28 }, + playerDoubles = { key = "healthbox_player_doubles", ox = -64, oy = -28 }, } Gen4Battle.HEALTHBOX_SIZE = { w = 128, h = 64 } @@ -1583,9 +1610,21 @@ function Gen4Battle.drawHealthboxes(battle) { side = "enemy", battler = battle.enemy, slot = 1 }, { side = "player", battler = battle.player, slot = 0 }, } + -- A DOUBLE has four boxes (healthbox.c sHealthBoxTemplate_PlayerSlot1 .. + -- _EnemySlot2): the foe's two in the solo enemy art at types 3 and 5, the + -- player's two in healthbox_player_doubles on the short cell at 2 and 4. + local double = Gen4Battle.doubleSlots(battle) and not battle.gen4Safari + if double then + sides = {} + for _, p in ipairs({ 1, 3, 0, 2 }) do + sides[#sides + 1] = { side = (p % 2 == 1) and "enemy" or "player", + battler = battle:battlerAt(p), slot = p + 2, double = true } + end + end local drew = 0 for _, row in ipairs(sides) do local art = Gen4Battle.HEALTHBOX_ART[row.side] + if row.double and row.side == "player" then art = Gen4Battle.HEALTHBOX_ART.playerDoubles end -- THE SAFARI BOX stands where the player's would: "SAFARI BALLS" and -- "Left: NN" in FONT_SYSTEM at the cell rows HealthBox_DrawBallCount and -- _DrawBallsLeftMessage copy them to (tile rows 2 and 4, from column 2). @@ -1639,6 +1678,7 @@ function Gen4Battle.drawHealthboxes(battle) end ::continue:: end + if double then return drew >= 2 end return drew == 2 end @@ -2999,7 +3039,10 @@ function Gen4Battle.drawBottomScreen(battle, SecondScreen, over) else rect = Gen4Battle.MOVE_RECTS[battle.moveIndex or 1] end - if rect and not Gen4Battle.drawCursorCorners(battle, rect) then + -- hidden until a key is pressed (BattleSystem_MenuKeys); a cache + -- without the corners keeps its always-on wash + local hidden = battleArtData(battle) and not battle.gen4CursorActive + if rect and not hidden and not Gen4Battle.drawCursorCorners(battle, rect) then local Theme = require("src.ui.Theme") local Font = require("src.render.Font") g.setColor(1, 1, 1, 0.25) @@ -3147,11 +3190,91 @@ Gen4Battle.BALL_STATUS_ANIM = { [0] = 0, 1, 3, 2 } -- (BattleSubscreen_ShowBallSprites at command selection, hidden again the -- moment a menu is left). The player's six at (12 + 19i, 13), the foe's six -- at (246 - 12i, 9) and only in a trainer battle. +-- THE BALL OF A POKEMON CLOSE TO LEVELLING UP WOBBLES (battle_subscreen.c +-- SysTask_AnimatePartyBalls). Each ball whose Pokemon is at 87% or more of +-- the way to its next level (Pokemon_GetPercentToNextLevel; 0 when fainted, +-- and in link, Safari, Frontier and Pal Park battles) waits 10 + 0..7 frames, +-- then rocks: right for `duration` frames, left for twice that, right again +-- -- each frame turning 0x800 (11.25 degrees) and sliding 0x180 (1.5 px) -- +-- twice, the second time with `duration` one less, and starts over. +function Gen4Battle.expPercents(battle, party) + local out = { 0, 0, 0, 0, 0, 0 } + if battle.gen4Safari or battle.noItems or battle.link then return out end + local Growth = require("src.pokemon.Growth") + local data = battle.data or (battle.game and battle.game.data) or {} + local n = 0 + for _, mon in ipairs(party or {}) do + local egg = mon and (mon.isEgg or mon.egg) + if mon and mon.species and mon.species ~= 0 and not egg and n < 6 then + n = n + 1 + local def = data.pokemon and data.pokemon[mon.species] + local level = tonumber(mon.level) or 1 + if def and (tonumber(mon.hp) or 0) > 0 and level < 100 then + local base = Growth.expForLevel(def.growthRate, level) + local nxt = Growth.expForLevel(def.growthRate, level + 1) + local exp = tonumber(mon.exp) or base + if nxt > base then out[n] = math.floor(math.max(0, exp - base) * 100 / (nxt - base)) end + end + end + end + return out +end + +function Gen4Battle.ballWobbleStep(st, percent, rand) + if st.phase == 0 then + if percent < 87 then return end + st.delay = 10 + (rand or 0) % 8 + st.duration, st.count, st.bounce, st.x, st.rot = 2, 0, 0, 0, 0 + st.phase = 1 + elseif st.phase == 1 then + if st.delay > 0 then st.delay = st.delay - 1 else st.phase = 2 end + elseif st.phase == 2 or st.phase == 4 then + st.rot, st.x, st.count = st.rot + 1, st.x + 1.5, st.count + 1 + if st.count >= st.duration then + st.count = 0 + if st.phase == 2 then st.phase = 3 + else + st.rot, st.x = 0, 0 + if st.bounce < 1 then + st.bounce = st.bounce + 1 + if st.duration > 1 then st.duration = st.duration - 1 end + st.phase = 2 + else + st.bounce, st.phase = 0, 0 + end + end + end + elseif st.phase == 3 then + st.rot, st.x, st.count = st.rot - 1, st.x - 1.5, st.count + 1 + if st.count >= st.duration * 2 then st.count = 0; st.phase = 4 end + end +end + function Gen4Battle.drawSubscreenBalls(battle) if not battleArt(battle) then return false end - local mine = Gen4Battle.stockStatus(playerParty(battle)) + local party = playerParty(battle) + local mine = Gen4Battle.stockStatus(party) + local percents = Gen4Battle.expPercents(battle, party) + battle.gen4BallWobble = battle.gen4BallWobble or {} + local frame = math.floor(battle.frame or 0) + local last = battle.gen4BallWobbleFrame or frame + local steps = math.max(0, math.min(8, frame - last)) + battle.gen4BallWobbleFrame = frame for i = 0, 5 do - drawArt(battle, "stock_player_" .. Gen4Battle.BALL_STATUS_ANIM[mine[i + 1]], 12 + 19 * i, 13) + local st = battle.gen4BallWobble[i] or { phase = 0, x = 0, rot = 0 } + battle.gen4BallWobble[i] = st + for _ = 1, steps do Gen4Battle.ballWobbleStep(st, percents[i + 1], love.math.random(0, 7)) end + local key = "stock_player_" .. Gen4Battle.BALL_STATUS_ANIM[mine[i + 1]] + local art = battleArt(battle) + local row = art and art[key] + local img = row and image(row.path) + if img and (st.rot or 0) ~= 0 or (img and (st.x or 0) ~= 0) then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, 12 + 19 * i + math.floor(st.x or 0), 13, (st.rot or 0) * math.pi / 16, + 1, 1, -(row.originX or 0), -(row.originY or 0)) + else + drawArt(battle, key, 12 + 19 * i, 13) + end end if battle.kind == "trainer" then local theirs = Gen4Battle.stockStatus(battle.enemyParty) @@ -3190,6 +3313,58 @@ Gen4Battle.TARGET_NAME_SPOTS = { [2] = { x = 196, y = 116 }, [3] = { x = 60, y = 32 }, } +-- sTargetSelectIconPositions: only the foes' two get an icon (slots 1, 3) +Gen4Battle.TARGET_ICON_SPOTS = { [1] = { x = 196, y = 56 }, [3] = { x = 60, y = 56 } } + +-- The icon's animation (BattleSubscreen_NewPokeIconSprite): still when +-- fainted, otherwise faster the healthier -- HealthBar_Color 4 (full) anim 1 +-- .. 1 (red) anim 4, a status condition anim 5. The icon sheets carry two +-- frames; pl_poke_icon's shared_anim_32k steps them every 4 / 6 / 12 / 20 +-- frames. Anim 5 holds frame 0 for 32 frames and then nudges the cell for +-- 2 + 2 (its position frames), drawn here as a one-pixel shake. +Gen4Battle.ICON_ANIM_PERIOD = { [1] = 4, [2] = 6, [3] = 12, [4] = 20 } +function Gen4Battle.iconAnimFor(mon) + local hp = tonumber(mon and mon.hp) or 0 + if hp <= 0 then return 0 end + if mon.status and mon.status ~= "" then return 5 end + local max = math.max(1, (mon.stats and mon.stats.hp) or mon.maxHp or 1) + local px = math.floor(hp * 48 / max) + if px >= 48 then return 1 elseif px > 24 then return 2 elseif px > 9 then return 3 end + return 4 +end + +local iconImages = {} +function Gen4Battle.drawTargetIcon(battle, mon, cx, cy) + local data = battle.data or (battle.game and battle.game.data) or {} + local icons = data.icons + local def = data.pokemon and data.pokemon[mon.species] + local entry = (icons and icons.bySpecies and icons.bySpecies[mon.species]) or (def and def.icon) + local path = type(entry) == "table" and entry.image or entry + if type(path) ~= "string" then return false end + if iconImages[path] == nil then + local ok, img = pcall(require("src.render.Assets").image, path) + iconImages[path] = ok and img or false + end + local img = iconImages[path] + if not img then return false end + local iw, ih = img:getDimensions() + local fh = math.min(ih, tonumber(type(entry) == "table" and entry.frameHeight) or 32) + local frames = math.max(1, math.floor(ih / fh)) + local anim = Gen4Battle.iconAnimFor(mon) + local f, dx = 0, 0 + local tick = math.floor(battle.frame or 0) + if anim == 5 then + local t = tick % 36 + if t >= 32 then dx = (t < 34) and 1 or -1 end + elseif anim > 0 and frames > 1 then + f = math.floor(tick / Gen4Battle.ICON_ANIM_PERIOD[anim]) % frames + end + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, love.graphics.newQuad(0, f * fh, iw, fh, iw, ih), + math.floor(cx - iw / 2) + dx, math.floor(cy - fh / 2)) + return true +end + -- slot -> index into battle.targetChoices, for the slots that can be chosen function Gen4Battle.targetSlots(battle) local out = {} @@ -3225,6 +3400,12 @@ function Gen4Battle.drawTargetSelect(battle, layer) end g.draw(layer, quad(Gen4Battle.TARGET_CANCEL), Gen4Battle.TARGET_CANCEL.left, Gen4Battle.TARGET_CANCEL.top) end + -- THE FOES' ICONS (BattleSubscreen_NewPokeIconSprite, slots 1 and 3 only) + for slot, i in pairs(slots) do + local spot = Gen4Battle.TARGET_ICON_SPOTS[slot] + local b = battle.targetChoices[i] + if spot and b and b.mon then pcall(Gen4Battle.drawTargetIcon, battle, b.mon, spot.x, spot.y) end + end local faced = Font.pushFace("subscreen") for slot, i in pairs(slots) do local b = battle.targetChoices[i] @@ -3232,22 +3413,34 @@ function Gen4Battle.drawTargetSelect(battle, layer) local okP, Pokemon = pcall(require, "src.pokemon.Pokemon") local gender = okP and Pokemon.genderOf and b and b.mon and Pokemon.genderOf(battle.data, b.mon) - if gender == "male" then name = name .. "♂" elseif gender == "female" then name = name .. "♀" end + -- BattleStrings 962 / 963: the nickname, then {COLOR 3}♂ or {COLOR 4}♀ + -- -- the symbol in palette 6's colours 7/8 or 9/10 (fg = 2n + 1) + local sym = (gender == "male" and "♂") or (gender == "female" and "♀") or nil local a, s = 4, 5 if slot % 2 == 1 then a, s = 1, 2 end + local spot = Gen4Battle.TARGET_NAME_SPOTS[slot] + local nw = Font.width and Font.width(name) or (#name * 8) + local sw = sym and (Font.width and Font.width(sym) or 8) or 0 + local x = math.floor(spot.x - (nw + sw) / 2) local tone = ink and ink[a] and Font.beginTwoTone(rgb01(ink[a]), rgb01(ink[s])) if not tone then g.setColor(0, 0, 0, 1) end - local spot = Gen4Battle.TARGET_NAME_SPOTS[slot] - local tw = Font.width and Font.width(name) or (#name * 8) - Font.draw(name, math.floor(spot.x - tw / 2), spot.y - 8) + Font.draw(name, x, spot.y - 8) if tone then Font.endTwoTone() end + if sym then + local fa, fs = 7, 8 + if gender == "female" then fa, fs = 9, 10 end + local t2 = ink and ink[fa] and Font.beginTwoTone(rgb01(ink[fa]), rgb01(ink[fs])) + if not t2 then g.setColor(0, 0, 0, 1) end + Font.draw(sym, x + nw, spot.y - 8) + if t2 then Font.endTwoTone() end + end end if faced then Font.popFace() end -- CANCEL in the bar's own colours, as the move list's is drawLabelAt(battle, Strings("CANCEL"), Gen4Battle.CANCEL_LABEL_SPOT) local chosen = battle.targetChoices and battle.targetChoices[battle.targetIndex or 1] local rect = chosen and Gen4Battle.TARGET_RECTS[tonumber(chosen.position) or -1] - if rect then Gen4Battle.drawCursorCorners(battle, rect) end + if rect and battle.gen4CursorActive then Gen4Battle.drawCursorCorners(battle, rect) end g.setColor(1, 1, 1, 1) return true end @@ -3391,6 +3584,20 @@ function Gen4Battle.partyScreenId(battle) return nil end +-- WHICH BAG A PLATINUM BATTLE OPENS: the cartridge's battle bag on the +-- bottom screen (src/ui/Gen4BattleBag.lua) by the same rule as the party +-- list -- bottom screen up, the art in the cache, no mod serving "BagMenu". +-- nil means "BagMenu", the field bag, as before. +function Gen4Battle.bagScreenId(battle) + if not (battle and battle.gen4Layout and battle:gen4Layout()) then return nil end + local data = battle.data or (battle.game and battle.game.data) + if data and data.screens and data.screens.BagMenu then return nil end + if not bottomScreenUp(battle.game) then return nil end + local ok, B = pcall(require, "src.ui.Gen4BattleBag") + if ok and B.available(data) then return "Gen4BattleBag" end + return nil +end + -- "WHAT WILL X DO?" -- BattleStrings 921 (922 "What will X throw?" in the -- Great Marsh), printed at once (speed 0) by BattleDisplay's command -- selection and left in the message box for as long as the player is diff --git a/src/core/Data.lua b/src/core/Data.lua index 7543416ef..c3eb6ad92 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -211,6 +211,9 @@ local GEN4_PREFIXED = { "gen4_menus", "gen4_graphics", "gen4_intro", -- PLATINUM'S TOWN MAP: its sprites, name blocks and -- fly locations (Gen4TownMap). "gen4_town_map", + -- ...and its bottom screen: the zoomed map, zoom + -- button, signposts and descriptions (Gen4TownMapArt). + "gen4_town_map_art", "gen4_town_map_ink", -- THE AREA-NAME SIGNS and the per-header label data -- (Gen4AreaPopup). "gen4_area_popup", @@ -272,6 +275,9 @@ local GEN4_PREFIXED = { "gen4_menus", "gen4_graphics", "gen4_intro", -- (Gen4EvolutionArt). "gen4_poketch_art", "gen4_poketch_ink", "gen4_evolution_art", "gen4_evolution_ink", + -- the field start menu's icons in both palettes and + -- their swell/wiggle animations (Gen4StartMenuArt). + "gen4_start_menu_art", "gen4_start_menu_ink", -- each trainer class's prize multiplier (Gen4TrainerPrize). "gen4_trainer_prize", -- each trainer class's front picture (the Hall of Fame's player). @@ -2183,6 +2189,23 @@ function Data:load() -- Optional, each one, and each says so in the log: a cache imported before -- a stage existed must boot and lose that screen's art, not refuse to start. if isGen4Cache then + -- WHICH GEN 4 CARTRIDGE. A HeartGold or SoulSilver cache writes + -- gen4_version with family "hgss"; its script ids dispatch through HGSS's + -- own bands and its menus read HGSS's bank (Gen4ScriptBands.setFamily). + -- Platinum's cache has no such file and keeps Platinum's tables. + do + local okV, ver = loadModule(dir, "gen4_version") + self.gen4_version = okV and ver or nil + local family = okV and type(ver) == "table" and ver.family or "platinum" + -- Black and White write gen5_version, with the cartridge's script id table + local rows + if not okV then + local ok5, v5 = loadModule(dir, "gen5_version") + if ok5 and type(v5) == "table" and v5.family == "bw" then family, rows = "bw", v5.scriptBands end + end + local okB, Bands = pcall(require, "src.import.Gen4ScriptBands") + if okB then Bands.setFamily(family, rows) end + end for _, name in ipairs(GEN4_PREFIXED) do local ok, mod = loadModule(dir, name) self[name] = ok and mod or nil diff --git a/src/core/Game.lua b/src/core/Game.lua index 13c990dd7..e8cf02854 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -640,6 +640,9 @@ function Game.worldBgBattleDim(stack) for i = #(stack and stack.states or {}), 1, -1 do local state = stack.states[i] if state and state.bgMode and state:bgMode() == "world" then + -- not when a mod is fighting the battle ON the map: the world is the + -- battle then, and dimming it blacks out the fight (BattleState:worldStaged) + if state.worldStaged and state:worldStaged() then return nil end return state.BG_WORLD_DIM or 0.55 end end diff --git a/src/core/GameVersion.lua b/src/core/GameVersion.lua index 0e4f21613..156020319 100644 --- a/src/core/GameVersion.lua +++ b/src/core/GameVersion.lua @@ -984,6 +984,90 @@ GameVersion.VERSIONS = { -- VERSION rather than testing the generation number in a dozen places. dualScreen = true, }, + -- HEARTGOLD AND SOULSILVER, Gen 4 on Platinum's engine: pret/pokeheartgold + -- builds both, and both dumps hash to what it builds. The Gen 4 extractor + -- reads them through src/import/Gen4Versions.lua -- their own paths (almost + -- everything under /a/x/y/z) and their own text banks over Platinum's. + -- Held at importable = false ("COMING SOON") until the overworld imports; + -- POKEPORT_UNLOCK=heartgold,soulsilver opens the import for development. + -- Two screens, Platinum's camera: both follow from generation 4 and + -- `dualScreen`, the same as Platinum, with nothing version-specific added. + heartgold = { + id = "heartgold", + generation = 4, + family = "hgss", + label = "HeartGold", + displayName = "Pokemon HeartGold", + launcherName = "HeartGold", + sha1 = "4fcded0e2713dc03929845de631d0932ea2b5a37", -- IPKE, USA + gameCode = "IPKE", + manifest = "tools/rom_manifest_heartgold.json", + cachePrefix = "heartgold/", + saveSuffix = "_heartgold", + importable = false, + experimental = true, + experimentalLabel = "EARLY", + hasRunning = true, + dualScreen = true, + }, + soulsilver = { + id = "soulsilver", + generation = 4, + family = "hgss", + label = "SoulSilver", + displayName = "Pokemon SoulSilver", + launcherName = "SoulSilver", + sha1 = "f8dc38ea20c17541a43b58c5e6d18c1732c7e582", -- IPGE, USA/Australia + gameCode = "IPGE", + manifest = "tools/rom_manifest_soulsilver.json", + cachePrefix = "soulsilver/", + saveSuffix = "_soulsilver", + importable = false, + experimental = true, + experimentalLabel = "EARLY", + hasRunning = true, + dualScreen = true, + }, + -- BLACK AND WHITE, Gen 5: squiddonaut/pokeblack decompiles Black and builds + -- exactly this dump (IRBO); White is the same engine and data layout with + -- its own version exclusives. Two screens and a 3D field like Platinum, so + -- the same second-screen modes, single-screen mode and camera controls. + black = { + id = "black", + generation = 5, + family = "bw", + label = "Black", + displayName = "Pokemon Black", + launcherName = "Black", + sha1 = "26ad0b9967aa279c4a266ee69f52b9b2332399a5", -- IRBO, USA/Europe (NDSi Enhanced) + gameCode = "IRBO", + manifest = "tools/rom_manifest_black.json", + cachePrefix = "black/", + saveSuffix = "_black", + importable = false, + experimental = true, + experimentalLabel = "EARLY", + hasRunning = true, + dualScreen = true, + }, + white = { + id = "white", + generation = 5, + family = "bw", + label = "White", + displayName = "Pokemon White", + launcherName = "White", + sha1 = "bc696a0dfb448c7b3a8a206f0f8214411a039208", -- IRAO, USA/Europe (NDSi Enhanced) + gameCode = "IRAO", + manifest = "tools/rom_manifest_white.json", + cachePrefix = "white/", + saveSuffix = "_white", + importable = false, + experimental = true, + experimentalLabel = "EARLY", + hasRunning = true, + dualScreen = true, + }, } -- Launcher column order: the CARTRIDGES in generation order, then the ROM @@ -993,6 +1077,7 @@ GameVersion.VERSIONS = { -- ordering rule tests/polished_crystal_registration_test.lua asserts. GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald", "platinum", + "heartgold", "soulsilver", "black", "white", "prism", "polishedcrystal" } GameVersion.current = "red" @@ -1038,6 +1123,26 @@ function GameVersion.isGen4(id) return GameVersion.generation(id) == 4 end +-- The fifth, Black and White. Same caveat again: a predicate, not support. +function GameVersion.isGen5(id) + return GameVersion.generation(id) == 5 +end + +-- A Nintendo DS cartridge: a filesystem, two screens, a 3D field. +function GameVersion.isNds(id) + return GameVersion.generation(id) >= 4 +end + +-- TWO SCREENS: `GameVersion.isDualScreen` below. src/ui/SecondScreen.lua +-- offers every second-screen mode (OFF among them -- the single-screen +-- layout) wherever it is true: Platinum, HeartGold, SoulSilver, Black, White. + +-- The game's family within its generation ("platinum", "hgss", "bw"). +function GameVersion.family(id) + local info = GameVersion.info(id) + return info and (info.family or info.id) or nil +end + -- THE MONEY SIGN THIS CARTRIDGE'S FONT DRAWS, which is a different character -- in every generation and is never a dollar. -- diff --git a/src/core/Sound.lua b/src/core/Sound.lua index c86f47635..7093a32f5 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -315,6 +315,173 @@ function Sound.playId(data, id) return Sound.play(data, name) end +-- --------------------------------------------------------------------------- +-- GEN 4 SOUND EFFECTS: `Sound_PlayEffect(SEQ_SE_...)`, by the cartridge's name. +-- +-- A DS sound effect is a sequence like any song: an SSEQ in the SDAT, played +-- by NitroAudio over the same banks and wave archives the BGM uses, on one of +-- the SE players. The importer (Gen4Audio.catalogue) files every sequence the +-- SDAT's SYMB block names under that name AND its id in data.audio.sfx, so the +-- vocabulary is the ROM's own: "SEQ_SE_DP_SELECT", "SEQ_SE_DP_CARD5", 1500. +-- +-- The decomp spells the same effects as file constants -- `SEQ_SE_DP_DECIDE_sseq` +-- or, for a file several entries share, `SE_CONFIRM_sseq_3` (the 4th SEQ entry +-- whose file is SE_CONFIRM.sseq; nitrosfx's -naix numbering). Those resolve +-- too, so a screen can be ported with the constant its C uses: the suffix +-- picks the Nth sequence sharing the anchor's FAT file, read off the ROM. +-- A FAT file has no name in the ROM, so the few files the decomp RENAMED (its +-- file name is no sequence's SYMB name) are anchored here to a sequence that +-- plays them; every other constant is read straight off the ROM's names and +-- FAT ids. tools/gen4_sound_effects_check.lua resolves all 1013 decomp +-- constants against res/sound/pl_sound_data.json to keep this table honest. +-- --------------------------------------------------------------------------- +local DECOMP_FILE_ANCHOR = { + SE_CONFIRM = "SEQ_SE_DP_SELECT", + SE_DP_VS_SEEKER_BEEP = "SEQ_SE_DP_UG_005", + SE_PARTY_GAUGE_ARROW_IN = "SEQ_SE_DP_TB_START", + SE_PARTY_GAUGE_BALL_IN = "SEQ_SE_DP_TB_KON", + SE_PARTY_GAUGE_BALL_EMPTY = "SEQ_SE_DP_TB_KARA", + BATTLE_WILD_POKEMON = "SEQ_BA_POKE", BATTLE_GYM_LEADER = "SEQ_BA_GYM", + BATTLE_LAKE_GUARDIAN = "SEQ_BA_DPOKE1", BATTLE_TRAINER = "SEQ_BA_TRAIN", + BATTLE_CYRUS = "SEQ_BA_AKAGI", BATTLE_DIALGA_PALKIA = "SEQ_BA_DPOKE2", + BATTLE_CHAMPION = "SEQ_BA_CHANP", BATTLE_GALACTIC_GRUNT = "SEQ_BA_GINGA", + BATTLE_RIVAL = "SEQ_BA_RIVAL", BATTLE_ARCEUS = "SEQ_BA_SECRET1", + BATTLE_LEGENDARY = "SEQ_BA_SECRET2", VICTORY_WILD_POKEMON = "SEQ_WINPOKE", + VICTORY_TRAINER = "SEQ_WINTRAIN", VICTORY_GYM_LEADER = "SEQ_WINTGYM", + VICTORY_CHAMPION = "SEQ_WINCHAMP", VICTORY_GALACTIC_GRUNT = "SEQ_WINGINGA", + VICTORY_CYRUS = "SEQ_WINAKAGI", VICTORY_ELITE_FOUR = "SEQ_WINTENNO", + BATTLE_GALACTIC_CMDR = "SEQ_BA_GINGA3", CONTEST_DRESSING_ROOM = "SEQ_CON_TEST", + BATTLE_ELITE_FOUR = "SEQ_BA_TENNO", BATTLE_GIRATINA = "SEQ_PL_BA_GIRA", + BATTLE_FRONTIER_BRAIN = "SEQ_PL_BA_BRAIN", VICTORY_FRONTIER_BRAIN = "SEQ_PL_WINBRAIN", + BATTLE_REGI_TRIO = "SEQ_PL_BA_REGI", +} + +local function nativeDef(def) + return type(def) == "table" and def.nds ~= nil and def or nil +end + +-- sequences grouped by the FAT file they play, per SDAT (weak: a re-import +-- drops it with the archive) +local sharedFiles = setmetatable({}, { __mode = "k" }) +local function archiveOf(data) + local ok, arc = pcall(function() + return require("src.audio.NitroAudio").archive(data) + end) + return ok and arc or nil +end +local function filesOf(arc) + local byFile = sharedFiles[arc] + if byFile then return byFile end + local Sdat = require("src.import.Gen4Sdat") + byFile = { byId = {} } + for i = 0, Sdat.count(arc, Sdat.SEQ) - 1 do + local seq = Sdat.sequence(arc, i) + if seq then + local list = byFile[seq.file] or {} + list[#list + 1] = i + byFile[seq.file], byFile.byId[i] = list, seq + end + end + sharedFiles[arc] = byFile + return byFile +end + +-- Sound.seDef(data, key) -> the native def, or nil. key: a SYMB name, a +-- sequence id, or a decomp `_sseq[_N]` constant. +function Sound.seDef(data, key) + local sfx = data and data.audio and data.audio.sfx + if not sfx or key == nil then return nil end + local direct = nativeDef(sfx[key]) + if direct or type(key) ~= "string" then return direct end + local base, n = key:match("^(.-)_sseq_(%d+)$") + if not base then base = key:match("^(.-)_sseq$"); n = base and 0 end + if not base then return nil end + n = tonumber(n) + local anchor = nativeDef(sfx[base]) + or nativeDef(sfx[DECOMP_FILE_ANCHOR[base] or false]) + if not anchor then return nil end + local arc = archiveOf(data) + local byFile = arc and filesOf(arc) + local seq = byFile and byFile.byId[tonumber(anchor.nds)] + local list = seq and byFile[seq.file] + if not list then return n == 0 and nativeDef(sfx[base]) or nil end + return nativeDef(sfx[list[n + 1] or false]) +end + +function Sound.seName(data, key) + local def = Sound.seDef(data, key) + return def and (def.name or def.nds) +end + +-- THE SE PLAYERS. Each SEQ INFO record names a player, and an SE player has +-- room for few sequences (PLAYER_SE_* hold one): a new effect takes the slot +-- of the lowest-priority one sounding when its own priority is at least that +-- (NNS_SndArcPlayerStartSeq), and otherwise does not start. +local playerSlots = {} +local function playerFor(data, def) + local arc = archiveOf(data) + if not arc then return nil end + local seq = filesOf(arc).byId[tonumber(def.nds)] + if not seq or not seq.player then return nil end + local info = require("src.import.Gen4Sdat").player(arc, seq.player) + return seq.player, info and info.maxSequences or 0, seq.playerPriority or 64 +end +local function sounding(src) + local ok, playing = pcall(src.isPlaying, src) + return ok and playing +end + +-- Sound.playSe(data, key[, opts]) -> the started source, or nil. +-- opts.pitch: a playback-rate factor (Sound_SetPitchForHandle callers). +-- opts.handle: an explicit sound handle (Sound_PlaySequenceWithPlayer), so one +-- effect can sound several times at once on separate players; it bypasses +-- the SE player slots because the caller picked its player itself. +function Sound.playSe(data, key, opts) + local def = Sound.seDef(data, key) + if not def then return nil end + local name = def.name or def.nds + if opts and opts.handle ~= nil then + local src = playPath(data, ("%s#%s"):format(tostring(name), tostring(opts.handle)), def) + if not src then return nil end + if src.setPitch then pcall(src.setPitch, src, opts.pitch or 1) end + played("sfx", name) + return src + end + local player, room, priority = playerFor(data, def) + local slots = player and room and room > 0 and (playerSlots[player] or {}) + if slots then + for i = #slots, 1, -1 do + if slots[i].name == name or not sounding(slots[i].src) then table.remove(slots, i) end + end + if #slots >= room then + local low = 1 + for i = 2, #slots do if slots[i].priority < slots[low].priority then low = i end end + if priority < slots[low].priority then return nil end + pcall(slots[low].src.stop, slots[low].src) + table.remove(slots, low) + end + end + local src = Sound.play(data, name) + if not src then return nil end + if src.setPitch then pcall(src.setPitch, src, opts and opts.pitch or 1) end + if slots then + slots[#slots + 1] = { name = name, src = src, priority = priority } + playerSlots[player] = slots + end + return src +end + +function Sound.isSePlaying(data, key, handle) + local name = Sound.seName(data, key) + if name ~= nil and handle ~= nil then name = ("%s#%s"):format(tostring(name), tostring(handle)) end + return name ~= nil and Sound.isPlaying(name) +end + +function Sound.stopSe(data, key) + local name = Sound.seName(data, key) + if name ~= nil then Sound.stop(name) end +end + -- Play a move's sound with its MoveSoundTable pitch/tempo modifiers -- (data/moves/sfx.asm; GetMoveSound loads them into wFrequencyModifier/ -- wTempoModifier and the battle sound engine applies them to every diff --git a/src/import/Gen4BattleArt.lua b/src/import/Gen4BattleArt.lua index 79b22b864..269ea8172 100644 --- a/src/import/Gen4BattleArt.lua +++ b/src/import/Gen4BattleArt.lua @@ -244,6 +244,216 @@ local function partyImages(rom, out) end end +-- --------------------------------------------------------------------------- +-- THE BATTLE PARTY LIST'S OTHER PAGES (battle_party.c +-- ScreenBackgroundDataMemberIndexes: member pairs { BG2, BG3 }). +-- +-- bsummary_bg/_fg SUMMARY members 4 / 5 +-- bmoves_bg/_fg CHECK MOVES members 2 / 3, palette row 12 swapped +-- for row 13 (LoadScreenPaletteData) +-- bmovesum_bg/_fg a move's summary members 6 / 7 +-- brestore_bg/_fg restore which PP members 8 / 9 +-- blearn_bg/_fg learn a move members 10 / 11 +-- bconfirm_bg/_fg forget this move members 14 / 15 +-- bparty_prev_ / bparty_next_ member 20 (5s, 49) / (5s, 54), 5x5 +-- bparty_small_1/_2 SUMMARY / CHECK MOVES pressing (0,44) / pressed (13,39) +-- bparty_movebtn_ member 21: (0,51) (16,51) (0,57) (16,57), 16x6 +-- bparty_learnbtn_ the same forced onto palette 10 (MOVE_TO_LEARN) +-- bparty_msbtn_ the move summary's move buttons, member 20 (5s, 59) 5x2 +-- bparty_confirm_ FORGET / CANCEL bar, member 20 (0, 24 + 5s) 26x5 +-- bsummary_exp tiles 22..30 (the EXP bar's 0..8 px fills) in the +-- palette member 4 has at (10, 8) +-- --------------------------------------------------------------------------- +Gen4BattleArt.PARTY_PAGES = { + bsummary = { 4, 5 }, bmoves = { 2, 3, swap = true }, bmovesum = { 6, 7 }, + brestore = { 8, 9 }, blearn = { 10, 11 }, bconfirm = { 14, 15 }, +} + +local function partyPages(rom, out) + local G = require("src.import.Gen4Graphics") + local member = opener(rom, Gen4BattleArt.PARTY_PATH) + if not member then return end + local sheet = G.tiles(member(Gen4BattleArt.PARTY_TILES)) + local pal = G.palette(member(Gen4BattleArt.PARTY_PALETTE)) + if not (sheet and pal) then return end + local swapped = {} + for i, c in ipairs(pal) do swapped[i] = c end + for i = 1, 16 do swapped[12 * 16 + i] = pal[13 * 16 + i] end + local function screen(i, p) + local map = G.tilemap(member(i)) + if not map then return nil end + local cells = {} + for n = 1, 32 * 24 do cells[n] = map.cells[n] or { tile = 0, palette = 0 } end + return G.compose({ width = 256, height = 192, cells = cells }, sheet, p) + end + for key, m in pairs(Gen4BattleArt.PARTY_PAGES) do + local p = m.swap and swapped or pal + out[key .. "_bg"], out[key .. "_fg"] = screen(m[1], p), screen(m[2], p) + end + local function block(mapIndex, x0, y0, w, h, forcePal) + local map = G.tilemap(member(mapIndex)) + if not map then return nil end + local cells = {} + for r = 0, h - 1 do + for c = 0, w - 1 do + local src = map.cells[(y0 + r) * 32 + x0 + c + 1] or { tile = 0, palette = 0 } + cells[#cells + 1] = { tile = src.tile, flipX = src.flipX, flipY = src.flipY, + palette = forcePal or src.palette } + end + end + return G.compose({ width = w * 8, height = h * 8, cells = cells }, sheet, pal) + end + for s = 0, 3 do + out["bparty_prev_" .. s] = block(20, 5 * s, 49, 5, 5) + out["bparty_next_" .. s] = block(20, 5 * s, 54, 5, 5) + end + out.bparty_small_1 = block(20, 0, 44, 13, 5) + out.bparty_small_2 = block(20, 13, 39, 13, 5) + local mv = { { 0, 51 }, { 16, 51 }, { 0, 57 }, { 16, 57 } } + for s = 0, 3 do + out["bparty_movebtn_" .. s] = block(21, mv[s + 1][1], mv[s + 1][2], 16, 6) + out["bparty_learnbtn_" .. s] = block(21, mv[s + 1][1], mv[s + 1][2], 16, 6, 10) + end + for s = 0, 2 do + out["bparty_msbtn_" .. s] = block(20, 5 * s, 59, 5, 2) + out["bparty_confirm_" .. s] = block(20, 0, 24 + 5 * s, 26, 5) + end + local bgMap = G.tilemap(member(4)) + local at = bgMap and bgMap.cells[8 * 32 + 10 + 1] + local cells = {} + for n = 0, 8 do cells[n + 1] = { tile = 22 + n, palette = at and at.palette or 0 } end + out.bsummary_exp = G.compose({ width = 9 * 8, height = 8, cells = cells }, sheet, pal) +end + +-- --------------------------------------------------------------------------- +-- THE BATTLE BAG (src/battle_sub_menus/battle_bag*.c), b_bag_gra.narc: tiles +-- 2, palette 3 (rows 0..11), member 0 the 512x512 BG2 (screen-block order), +-- member 1 the 32-wide button store. SetupBackgroundScroll shows the menu at +-- (0,0), the pocket at (256,0) and USE at (0,256), and USE recolours +-- (2,35) 28x4 and (2,40) 28x8 to palette 8 + pocket. +-- +-- bbag_menu / bbag_pocket / bbag_use_

the three 256x192 views +-- bbag_pocketbtn_

_ 16x9 from (0, 9s), colour 0, the pocket's 4x4 +-- icon from (20 + 4s, 47 + 4p) at column 6 +-- bbag_last_ 26x5 from (0, 27 + 5s), colour 3 (s = 3: the +-- pressed data in colour 5, DISABLED) +-- bbag_usebtn_

_ the same bar in colour 8 + p +-- bbag_cancel_ 5x5 from (5s, 57), colour 2 +-- bbag_item_

_ 16x6 from (16, 6s), colour 8 + p; _off is s 3 +-- in colour 5 +-- bbag_prev_ / bbag_next_ 5x5 from (5s, 47) / (5s, 52), colour 2 +-- (s = 3 in colour 5) +-- --------------------------------------------------------------------------- +Gen4BattleArt.BAG_PATH = "/battle/graphic/b_bag_gra.narc" + +local function bagImages(rom, out) + local G = require("src.import.Gen4Graphics") + local member = opener(rom, Gen4BattleArt.BAG_PATH) + if not member then return end + local sheet = G.tiles(member(2)) + local pal = G.palette(member(3)) + local big = G.tilemap(member(0)) + local store = G.tilemap(member(1)) + if not (sheet and pal and big and store) then return end + local function at(x, y) + local blockN = (y >= 32 and 2 or 0) + (x >= 32 and 1 or 0) + return big.cells[blockN * 1024 + (y % 32) * 32 + (x % 32) + 1] or { tile = 0, palette = 0 } + end + local function view(x0, y0, recolour) + local cells = {} + for r = 0, 23 do + for c = 0, 31 do + local src = at(x0 + c, y0 + r) + local p = src.palette + if recolour then p = recolour(x0 + c, y0 + r) or p end + cells[#cells + 1] = { tile = src.tile, flipX = src.flipX, flipY = src.flipY, palette = p } + end + end + return G.compose({ width = 256, height = 192, cells = cells }, sheet, pal) + end + out.bbag_menu = view(0, 0) + out.bbag_pocket = view(32, 0) + for p = 0, 3 do + out["bbag_use_" .. p] = view(0, 32, function(x, y) + if x >= 2 and x < 30 and ((y >= 35 and y < 39) or (y >= 40 and y < 48)) then return 8 + p end + end) + end + local function block(x0, y0, w, h, colour, overlay) + local cells = {} + for r = 0, h - 1 do + for c = 0, w - 1 do + local src = store.cells[(y0 + r) * 32 + x0 + c + 1] or { tile = 0, palette = 0 } + cells[#cells + 1] = { tile = src.tile, flipX = src.flipX, flipY = src.flipY, palette = colour } + end + end + if overlay then overlay(cells, w) end + return G.compose({ width = w * 8, height = h * 8, cells = cells }, sheet, pal) + end + for p = 0, 3 do + for s = 0, 2 do + out[("bbag_pocketbtn_%d_%d"):format(p, s)] = block(0, 9 * s, 16, 9, 0, function(cells, w) + for r = 0, 3 do + for c = 0, 3 do + local src = store.cells[(47 + 4 * p + r) * 32 + 20 + 4 * s + c + 1] or { tile = 0 } + cells[r * w + 6 + c + 1] = { tile = src.tile, flipX = src.flipX, flipY = src.flipY, palette = 0 } + end + end + end) + out[("bbag_usebtn_%d_%d"):format(p, s)] = block(0, 27 + 5 * s, 26, 5, 8 + p) + out[("bbag_item_%d_%d"):format(p, s)] = block(16, 6 * s, 16, 6, 8 + p) + end + end + out.bbag_item_off = block(16, 18, 16, 6, 5) + for s = 0, 2 do + out["bbag_last_" .. s] = block(0, 27 + 5 * s, 26, 5, 3) + out["bbag_cancel_" .. s] = block(5 * s, 57, 5, 5, 2) + out["bbag_prev_" .. s] = block(5 * s, 47, 5, 5, 2) + out["bbag_next_" .. s] = block(5 * s, 52, 5, 5, 2) + end + out.bbag_last_3 = block(0, 37, 26, 5, 5) + out.bbag_prev_3 = block(15, 47, 5, 5, 5) + out.bbag_next_3 = block(15, 52, 5, 5, 5) +end + +-- the bag's two window palettes: row 0 (palette 0, TEXT_COLOR(3,2,1)) and +-- row 4 (palette 4, TEXT_COLOR(1,2,0)), 0-based index -> { r, g, b } +local function bagInk(rom) + local G = require("src.import.Gen4Graphics") + local member = opener(rom, Gen4BattleArt.BAG_PATH) + local pal = member and G.palette(member(3)) + if not pal then return nil end + local out = {} + for _, r in ipairs({ 0, 4 }) do + local t = {} + for i = 0, 15 do + local c = pal[r * 16 + i + 1] + t[i] = c and { c[1], c[2], c[3] } or { 0, 0, 0 } + end + out["row" .. r] = t + end + return out +end + +-- The party pages' text palette 13: pl_font member 6 with entries 7-8 taken +-- from row 9's 10-11 and 3-4 from row 9's 12-13 (battle_party.c +-- LoadGraphicsData), 0-based index -> { r, g, b } +local function partyInk13(rom) + local G = require("src.import.Gen4Graphics") + local member = opener(rom, Gen4BattleArt.PARTY_PATH) + local pal = member and G.palette(member(Gen4BattleArt.PARTY_PALETTE)) + local font = opener(rom, "/graphic/pl_font.narc") + local fpal = font and G.palette(font(6)) + if not (pal and fpal) then return nil end + local t = {} + for i = 0, 15 do + local c = fpal[i + 1] + t[i] = c and { c[1], c[2], c[3] } or { 0, 0, 0 } + end + local function r9(i) local c = pal[9 * 16 + i + 1] return c and { c[1], c[2], c[3] } end + t[7], t[8], t[3], t[4] = r9(10) or t[7], r9(11) or t[8], r9(12) or t[3], r9(13) or t[4] + return t +end + -- the windows' palette (row 9 of member 23), 0-based index -> { r, g, b } local function partyInk(rom) local G = require("src.import.Gen4Graphics") @@ -262,6 +472,10 @@ function Gen4BattleArt.images(rom) local out = {} local okP, errP = pcall(partyImages, rom, out) if not okP then print("gen4 battle art: the battle party list did not extract: " .. tostring(errP)) end + local okPg, errPg = pcall(partyPages, rom, out) + if not okPg then print("gen4 battle art: the battle party pages did not extract: " .. tostring(errPg)) end + local okB, errB = pcall(bagImages, rom, out) + if not okB then print("gen4 battle art: the battle bag did not extract: " .. tostring(errB)) end local ok, err = walk(rom, function(set, sheet, bank, anim, colours) for s, seq in ipairs(anim.sequences or {}) do local frames = seq.frames or {} @@ -321,6 +535,10 @@ function Gen4BattleArt.data(rom) end local okP, r9 = pcall(partyInk, rom) if okP and r9 then out.partyInk = r9 end + local okB, bi = pcall(bagInk, rom) + if okB and bi then out.bagInk = bi end + local ok13, i13 = pcall(partyInk13, rom) + if ok13 and i13 then out.partyInk13 = i13 end return out end diff --git a/src/import/Gen4BoxArt.lua b/src/import/Gen4BoxArt.lua index 3520e0f9f..9ccc9582c 100644 --- a/src/import/Gen4BoxArt.lua +++ b/src/import/Gen4BoxArt.lua @@ -226,6 +226,64 @@ function Gen4BoxArt.images(rom) end end + -- THE BOTTOM SCREEN (ov19_021DCF88, every mode but COMPARE): sub BG tiles + -- 127 under palette 128 (three rows at sub BG 0); SUB_3 map 124 (the + -- backdrop with the two dial wells), SUB_2 map 125 (512 x 256: the dial's + -- cover, scrolled to 0 / -128 / 128) and SUB_1 map 126 (512 x 256: the two + -- dial faces, "SELECT BOX" / "MARKINGS", scrolled to 0 / -96 / 88). + local subSheet, subPal = G.tiles(member(127)), G.palette(member(128)) + if subSheet and subPal then + for key, m in pairs({ sub_bg = 124, sub_cover = 125, sub_dials = 126 }) do + local map = G.tilemap(member(m)) + if map then out[key] = G.compose(map, subSheet, subPal) end + end + end + -- sub OBJ palette 144 (Graphics_LoadPartialPaletteFromOpenNARC at sub OBJ + -- 0, through the sprite list's palette proxy: no explicit palette, so each + -- OAM entry's own row is the one used) + local subObj = G.palette(member(144)) + local function cellsOf(tiles, cells, anims, colours, prefix, forceRow) + local sh, bank, an = G.tiles(member(tiles)), Cells.parse(member(cells), G), Anim.parse(member(anims), G) + if not (sh and bank and an and colours) then return end + for i, cell in ipairs(bank.cells) do + out[prefix .. (i - 1)] = assemble(cell, sh, forceRow and row(colours, forceRow) or colours, bank, forceRow ~= nil) + end + end + -- the two corner buttons (NCGR/NCER/NANR 138/139/140): sequence 0 cell 0 + -- (up), 1 cells 1 x 8 then 2 x 8 (pressed), 2 cells 3 / 4; the right one + -- is the same sprite flipped (Sprite_SetFlipMode 1) + cellsOf(138, 139, 140, subObj, "sub_button_") + -- the markings dial's eight buttons (141/142/143): cell 2t off, 2t + 1 lit + -- -- OFF, ITEM, circle, triangle, square, heart, star, diamond + cellsOf(141, 142, 143, subObj, "sub_mark_") + + -- COMPARE MODE'S BOTTOM SCREEN (ov19_021DEC04): tiles 132, palette 133 + -- (four rows at sub BG 0), maps 129 (battle stats), 130 (contest) and 131 + -- (moves) on SUB_1 / SUB_2 / SUB_3, one shown per compare mode; sprites + -- 134/135/136 over palette 137 (three rows at sub OBJ 0), each with an + -- explicit palette -- the left button 0, the right 1 (flipped), the toggle + -- 2, the contest dots their slot's 0 / 1. + local cmpSheet, cmpPal = G.tiles(member(132)), G.palette(member(133)) + if cmpSheet and cmpPal then + for key, m in pairs({ cmp_battle = 129, cmp_contest = 130, cmp_moves = 131 }) do + local map = G.tilemap(member(m)) + if map then out[key] = G.compose(map, cmpSheet, cmpPal) end + end + end + local cmpObj = G.palette(member(137)) + if cmpObj then + local sh, bank = G.tiles(member(134)), Cells.parse(member(135), G) + if sh and bank then + local function one(cell, pal, key) + local c = bank.cells[cell + 1] + if c then out[key] = assemble(c, sh, row(cmpObj, pal), bank, true) end + end + for c = 0, 2 do one(c, 0, "cmp_left_" .. c); one(c, 1, "cmp_right_" .. c) end + for c = 3, 5 do one(c, 2, "cmp_toggle_" .. c) end + one(6, 0, "cmp_dot_0"); one(7, 1, "cmp_dot_1") + end + end + -- font_special_chars in the preview's two colour pairs local font = opener(rom, Gen4BoxArt.FONT_PATH) local special = font and G.tiles(font(require("src.import.Gen4SpecialChars").MEMBER)) @@ -305,7 +363,22 @@ function Gen4BoxArt.data(rom) local _, _, p = Gen4BoxArt.wallpaperMembers(n) papers[n] = G.palette(member(p)) end - return Gen4BoxArt.ink(pal, papers, G.palette(member(27))) + local ink = Gen4BoxArt.ink(pal, papers, G.palette(member(27))) + -- the bottom screen: ov19_021DE350's box names TEXT_COLOR(1, 2, 0) in + -- window palette 2 of member 128; compare mode's words TEXT_COLOR(15, 14, 0) + -- in palettes 0 (left windows) and 1 (right) of member 133; SUB backdrop + -- colour 0 of each + local function pair(p, r, a, b) + local x, y = p and p[r * 16 + a + 1], p and p[r * 16 + b + 1] + return x and y and { { x[1], x[2], x[3] }, { y[1], y[2], y[3] } } or nil + end + local sub, cmp = G.palette(member(128)), G.palette(member(133)) + ink.subName = pair(sub, 2, 1, 2) + ink.subBackdrop = sub and sub[1] and { sub[1][1], sub[1][2], sub[1][3] } + ink.cmpLeft = pair(cmp, 0, 15, 14) + ink.cmpRight = pair(cmp, 1, 15, 14) + ink.cmpBackdrop = cmp and cmp[1] and { cmp[1][1], cmp[1][2], cmp[1][3] } + return ink end return Gen4BoxArt diff --git a/src/import/Gen4Encounters.lua b/src/import/Gen4Encounters.lua index 4e024e227..eca64de50 100644 --- a/src/import/Gen4Encounters.lua +++ b/src/import/Gen4Encounters.lua @@ -213,6 +213,18 @@ end function Gen4Encounters.timedGrass(area, hour) local band = Gen4Encounters.timedBand(hour) if not band then return nil end + -- HEARTGOLD / SOULSILVER: a whole table per band (parseHgss) + if area and type(area.timed) == "table" and type(area.grass) == "table" then + local species = area.timed[band] + if type(species) ~= "table" then return nil end + local out = {} + for i, s in ipairs(area.grass) do + local sp = tonumber(species[i]) + out[i] = { level = s.level, minLevel = s.minLevel, maxLevel = s.maxLevel, chance = s.chance, + species = (sp and sp > 0) and sp or s.species } + end + return out + end local pair = area and area[band] local slots = area and area.grass if type(pair) ~= "table" or type(slots) ~= "table" then return nil end @@ -231,10 +243,89 @@ function Gen4Encounters.timedGrass(area, hour) return out end -function Gen4Encounters.all(archive) +-- --------------------------------------------------------------------------- +-- HEARTGOLD AND SOULSILVER +-- --------------------------------------------------------------------------- +-- +-- /fielddata/encountdata/g_enc_data.narc (HeartGold) and s_enc_data.narc +-- (SoulSilver): one EncounterData of 0xC4 bytes per area, a DIFFERENT record +-- from Platinum's 424 (pokeheartgold include/wild_encounter.h): +-- +-- 00 u8 rates: walking, surfing, rock smash, old rod, good rod, super rod +-- 06 2 padding +-- 08 u8 levels[12] -- ONE level per grass slot, shared by the three times +-- 14 u16 species_morn[12] 2C u16 species_day[12] 44 u16 species_nite[12] +-- 5C u16 hoennSounds[2] 60 u16 sinnohSounds[2] +-- 64 surf[5] 78 rockSmash[2] 80 oldRod[5] 94 goodRod[5] A8 superRod[5] +-- each { u8 minLevel, u8 maxLevel, u16 species } -- MINIMUM FIRST here, +-- the opposite of Platinum's water slots +-- BC u16 landSwarm BE surfSwarm C0 nightFish C2 fishSwarm +-- +-- THE WHOLE GRASS TABLE CHANGES WITH THE TIME, not two slots of it: +-- EncSlotArray_Init_Land (src/field/encounter_check.c) takes all twelve species +-- from species_morn, species_day (day and evening) or species_nite (night and +-- late night) -- the same hour bands as Platinum's TimeOfDayForHour, so +-- `timedBand` answers for both and `timedGrass` reads `timed` when it is here. +-- +-- The rolls (EncounterSlot_WildMonSlotRoll_*): grass and surf as Platinum's; +-- FISHING is 40/30/15/10/5, not 60/30/5/4/1; rock smash is 80/20. +Gen4Encounters.HGSS_RECORD_BYTES = 0xC4 +Gen4Encounters.HGSS_FISH_RATES = { 40, 30, 15, 10, 5 } +Gen4Encounters.HGSS_ROCK_SMASH_RATES = { 80, 20 } + +local function u16(s, o) local a, b = s:byte(o + 1, o + 2); return (a or 0) + (b or 0) * 256 end + +local function hgssBlock(record, at, n, rate, chances) + local out = { rate = rate, slots = {} } + for i = 0, n - 1 do + local o = at + i * 4 + out.slots[i + 1] = { minLevel = u8(record, o), maxLevel = u8(record, o + 1), + species = u16(record, o + 2), chance = chances[i + 1] } + end + return out +end + +function Gen4Encounters.parseHgss(record) + if type(record) ~= "string" or #record < Gen4Encounters.HGSS_RECORD_BYTES then + return nil, ("encounter record is %d bytes, expected %d") + :format(type(record) == "string" and #record or -1, Gen4Encounters.HGSS_RECORD_BYTES) + end + local function species(at) + local t = {} + for i = 0, Gen4Encounters.GRASS_SLOTS - 1 do t[i + 1] = u16(record, at + i * 2) end + return t + end + local timed = { morning = species(0x14), day = species(0x2C), night = species(0x44) } + local grass = {} + for i = 0, Gen4Encounters.GRASS_SLOTS - 1 do + local level = u8(record, 0x08 + i) + -- the base table is the morning's, as Platinum's is + grass[i + 1] = { level = level, minLevel = level, maxLevel = level, + species = timed.morning[i + 1], chance = Gen4Encounters.GRASS_RATES[i + 1] } + end + return { + family = "hgss", + grassRate = u8(record, 0), + grass = grass, + timed = timed, + hoennSounds = { u16(record, 0x5C), u16(record, 0x5E) }, + sinnohSounds = { u16(record, 0x60), u16(record, 0x62) }, + surf = hgssBlock(record, 0x64, 5, u8(record, 1), Gen4Encounters.WATER_RATES), + rockSmash = hgssBlock(record, 0x78, 2, u8(record, 2), Gen4Encounters.HGSS_ROCK_SMASH_RATES), + oldRod = hgssBlock(record, 0x80, 5, u8(record, 3), Gen4Encounters.HGSS_FISH_RATES), + goodRod = hgssBlock(record, 0x94, 5, u8(record, 4), Gen4Encounters.HGSS_FISH_RATES), + superRod = hgssBlock(record, 0xA8, 5, u8(record, 5), Gen4Encounters.HGSS_FISH_RATES), + hgssSwarm = { land = u16(record, 0xBC), surf = u16(record, 0xBE), + nightFish = u16(record, 0xC0), fish = u16(record, 0xC2) }, + } +end + +-- all(archive, family) -- family "hgss" reads the HeartGold/SoulSilver record +function Gen4Encounters.all(archive, family) + local parse = family == "hgss" and Gen4Encounters.parseHgss or Gen4Encounters.parse local out = {} for i = 0, archive.count - 1 do - local a = Gen4Encounters.parse(archive:get(i)) + local a = parse(archive:get(i)) if a then a.id = i; out[i] = a end end return out diff --git a/src/import/Gen4EvolutionArt.lua b/src/import/Gen4EvolutionArt.lua index c3f58331b..d67ee6a1c 100644 --- a/src/import/Gen4EvolutionArt.lua +++ b/src/import/Gen4EvolutionArt.lua @@ -30,14 +30,60 @@ function Gen4EvolutionArt.images(rom) if not list then return nil, "shinka particle file missing" end local out = {} for i, texture in ipairs(list) do out[("particle_%d"):format(i - 1)] = P.rgba(texture) end + -- BG3: egg_data.narc members 0 (tiles), 1 (tilemap) and 8 (two palettes to + -- rows 0-1) -- the diploma naix order the decomp's names come from: + -- sinnoh_document_NCGR, national_dex_document_NCGR, sinnoh_document_NSCR + local G = require("src.import.Gen4Graphics") + local egg = Gen4EvolutionArt.egg(rom) + if egg then + local sheet, map, pal = G.tiles(egg(0)), G.tilemap(egg(1)), G.palette(egg(8)) + if sheet and map and pal then out.background = G.compose(map, sheet, pal) end + end return out end +Gen4EvolutionArt.EGG_PATH = "/demo/egg/data/egg_data.narc" + +function Gen4EvolutionArt.egg(rom) + local G = require("src.import.Gen4Graphics") + local bytes = rom:read(Gen4EvolutionArt.EGG_PATH) + local narc = bytes and require("src.import.NarcArchive").parse(bytes) + if not narc then return nil end + return function(i) + local b = narc:get(i) + if b and G.isCompressed(b) then b = G.decompress(b) end + return b + end +end + function Gen4EvolutionArt.data(rom) local data = file(rom) if not data then return nil end local emitters = require("src.import.Gen4Particle").emitters(data) - return { emitters = emitters, count = emitters and #emitters or 0 } + local out = { emitters = emitters, count = emitters and #emitters or 0 } + -- each texture's repeat/flip bits (SPLTextureParam: format 0-3, s 4-7, + -- t 8-11, repeat 12-13, flip 14-15): a flipped repeat mirrors alternate + -- copies, which is how a quarter-circle texture tiled 2x2 draws a ring + local head = require("src.import.Gen4Particle").header(data) + if head then + out.textures = {} + local at = head.textureAt + for i = 1, head.textures do + local function u32(o) local a, b, c, d = data:byte(o + 1, o + 4); return d and a + b * 256 + c * 65536 + d * 16777216 end + local param, total = u32(at + 4), u32(at + 28) + if not (param and total and total > 0) then break end + local bits = math.floor(param / 4096) % 16 + out.textures[i] = { repeatS = bits % 2 == 1, repeatT = math.floor(bits / 2) % 2 == 1, + flipS = math.floor(bits / 4) % 2 == 1, flipT = math.floor(bits / 8) % 2 == 1 } + at = at + total + end + end + -- the backdrop: main BG palette colour 0 (row 0 of member 8) shows outside + -- window 0 while the scene letterboxes + local egg = Gen4EvolutionArt.egg(rom) + local pal = egg and require("src.import.Gen4Graphics").palette(egg(8)) + if pal and pal[1] then out.backdrop = { pal[1][1], pal[1][2], pal[1][3] } end + return out end return Gen4EvolutionArt diff --git a/src/import/Gen4Graphics.lua b/src/import/Gen4Graphics.lua index 312cdc057..146112dac 100644 --- a/src/import/Gen4Graphics.lua +++ b/src/import/Gen4Graphics.lua @@ -212,6 +212,32 @@ function Gen4Graphics.decryptSprite(pixels) return table.concat(out) end +-- DIAMOND AND PEARL'S ORDER, which HeartGold and SoulSilver keep for their +-- trainer pictures: pokeheartgold's UnscanPokepic (src/pokepic.c) sends +-- NARC_a_0_5_8 and NARC_a_0_0_6 -- the trainer fronts and backs -- to +-- UnscanPokepic_DP, which seeds from the LAST halfword and walks BACKWARDS, +-- where the Platinum/HGSS order above seeds from the first and walks forward. +-- Same key step; only the end it starts from differs. +function Gen4Graphics.decryptSpriteDP(pixels) + if type(pixels) ~= "string" or #pixels < 2 then return pixels end + local count = floor(#pixels / 2) + local key = byte(pixels, count * 2 - 1) + byte(pixels, count * 2) * 256 + local out = {} + for i = count, 1, -1 do + local lo, hi = byte(pixels, i * 2 - 1, i * 2) + local v = lo + hi * 256 + local r, place, a, b = 0, 1, v, key + for _ = 1, 16 do + local x, y = a % 2, b % 2 + if x ~= y then r = r + place end + a, b, place = floor(a / 2), floor(b / 2), place * 2 + end + out[i] = string.char(r % 256, floor(r / 256)) + key = (key * 0x41C64E6D + 0x6073) % 65536 + end + return table.concat(out) .. sub(pixels, count * 2 + 1) +end + -- --------------------------------------------------------------------------- -- The container -- --------------------------------------------------------------------------- diff --git a/src/import/Gen4HgssArea.lua b/src/import/Gen4HgssArea.lua new file mode 100644 index 000000000..d239c31a7 --- /dev/null +++ b/src/import/Gen4HgssArea.lua @@ -0,0 +1,47 @@ +-- HEARTGOLD AND SOULSILVER'S AREA RECORD (/a/0/4/2, 106 x 8 bytes). +-- +-- The same eight bytes as Platinum's AreaDataFile with the last two meaning +-- something else. Read from pokeheartgold's overlay 1, which loads the record +-- into AreaDataManager+0x8B0 and then reads it field by field +-- (asm/overlay_01_021FB878.s): +-- +-- +0 u16 buildings the area_build list, NARC 0x2B (/a/0/4/3), and the +-- building texture set, NARC 0x46 (/a/0/7/0) +-- +2 u16 mapTexture the map texture set, NARC 0x2C (/a/0/4/4) +-- +4 u16 (0, 1 or 0xFFFF; not read by the loader) +-- +6 u8 outdoors AreaDataManager_Alloc: nonzero opens the OUTDOOR +-- building models NARC 0x28 (/a/0/4/0), zero the indoor +-- ones NARC 0x94 (/a/1/4/8) +-- +7 u8 light AreaDataManager_GetAreaLightArchiveID: 0 -> 1, +-- 1 -> 0, 2 -> 3, anything else -> 0 +-- +-- MEASURED against the map headers that use each record: every record whose +-- maps are towns, routes or caves has +6 = 1, every one whose maps are rooms +-- has 0 (New Bark's houses, the gyms, the Pokemon Centers). + +local A = {} + +A.RECORD_BYTES = 8 +A.LIGHT_ARCHIVE = { [0] = 1, [1] = 0, [2] = 3 } +A.OUTDOOR_MODELS = "/a/0/4/0" +A.INDOOR_MODELS = "/a/1/4/8" + +-- parse(record) -> Platinum's field names (Gen4Maps.areaData), so the terrain +-- stage reads one shape for both games +function A.parse(record) + if type(record) ~= "string" or #record < A.RECORD_BYTES then return nil end + local b = { record:byte(1, A.RECORD_BYTES) } + local function u16(o) return b[o + 1] + b[o + 2] * 256 end + local outdoors = b[7] ~= 0 + return { + buildings = u16(0), + mapTexture = u16(2), + unused04 = u16(4), + outdoors = outdoors, + lightType = b[8], + areaLight = A.LIGHT_ARCHIVE[b[8]] or 0, + buildingModels = outdoors and A.OUTDOOR_MODELS or A.INDOOR_MODELS, + } +end + +return A diff --git a/src/import/Gen4HgssBattle.lua b/src/import/Gen4HgssBattle.lua new file mode 100644 index 000000000..056de5349 --- /dev/null +++ b/src/import/Gen4HgssBattle.lua @@ -0,0 +1,56 @@ +-- HEARTGOLD AND SOULSILVER'S BATTLE BACKDROPS (/a/0/0/7). +-- +-- The same 23 backdrops as Platinum in the same order (pokeheartgold +-- include/constants/battle.h enum BattleBg: GENERAL, OCEAN, CITY, FOREST ... +-- with Johto's Elite Four -- WILL, KOGA, BRUNO, KAREN, LANCE -- where +-- Platinum's sit), in the same archive shape as pl_batt_bg.narc, at the same +-- member arithmetic but one base: overlay 12's background load +-- (asm/overlay_12_022378C0.s) reads +-- +-- GfGfxLoader_LoadCharData(NARC 7, bg + 3) -- tiles, as Platinum +-- PaletteData_LoadNarc(NARC 7, bg * 3 + 0xB0 + time) -- 176, not 172 +-- ... the tilemap at member 2 -- as Platinum +-- +-- (the archive has four more members before its palettes than Platinum's, +-- 91 palettes from 175 against 91 from 171). The names are Gen4Battle's, by +-- index, because the battle screen looks a backdrop up by that name. + +local Battle = require("src.import.Gen4Battle") + +local B = {} + +B.PATH = "/a/0/0/7" +B.PALETTE_BASE = 0xB0 + +-- the HGSS spec for backdrop `index` at `time` (0 day, 1 evening, 2 night) +function B.background(index, time) + local spec = Battle.background(index, time) + if not spec then return nil end + spec.palette = B.PALETTE_BASE + index * Battle.BG_PALETTE_STRIDE + time + return spec +end + +-- backgrounds(x) -> { [name] = entry }, written, skipped +function B.backgrounds(x) + local arc = x:archiveAt(B.PATH) + local out, written, skipped = {}, 0, {} + if not arc then return out, 0, { B.PATH } end + for i = 0, Battle.BACKGROUND_COUNT - 1 do + for t = 0, #Battle.TIMES - 1 do + local spec = B.background(i, t) + local name = ("%s_%s"):format(spec.name, spec.time) + for _, gray in ipairs({ false, true }) do + local image = x:composeJob(arc, { tiles = spec.tiles, palette = spec.palette, + tilemap = spec.tilemap, grayscale = gray or nil }) + local key = gray and (name .. "_gray") or name + local entry = image and x:saveImage("battle/background/" .. key, image, { + background = i, time = spec.time, fadeTo = spec.fadeTo, grayscale = gray or nil, + }) + if entry then out[key] = entry; written = written + 1 else skipped[#skipped + 1] = key end + end + end + end + return out, written, skipped +end + +return B diff --git a/src/import/Gen4HgssConstants.lua b/src/import/Gen4HgssConstants.lua new file mode 100644 index 000000000..d9398f507 --- /dev/null +++ b/src/import/Gen4HgssConstants.lua @@ -0,0 +1,180 @@ +-- HEARTGOLD AND SOULSILVER'S `constants` (and `type_chart`). +-- +-- Platinum's constants stage (RomExtractorGen4:extractConstants) mixes the +-- game-independent with the Sinnoh-only: the type chart, the curves and the +-- TM list sit beside Poketch art, Honey trees, the Underground and Platinum's +-- mart -- whose paths HGSS does not have. This builds the table for HGSS from +-- the same finders, in its own module so Platinum's stage is left as it is. +-- +-- type chart Gen4TypeChart's signature scan over the overlays, which +-- NdsRom now unpacks (127 of HGSS's 129 are BLZ-compressed) +-- types, natures the HGSS banks Gen4Versions names (735, 34) +-- curves /a/0/0/3 (poketool_personal_growtbl), six of 101 u32 +-- TMs and HMs the same tail search as Platinum's with HGSS's HMs: +-- Cut, Fly, Surf, Strength, WHIRLPOOL (250, where Platinum +-- has Defog), Rock Smash, Waterfall, Rock Climb +-- badges pokeheartgold include/constants/badge.h, Johto then Kanto +-- new game the init band's script (scr_seq 149, entry 0): every +-- SetFlag / ClearFlag / SetVar it runs, as Platinum's is read +-- message window 2, 19, 27 x 4 -- the rectangle HGSS's own windows use +-- (options_app.c, choose_starter_app.c, naming_screen.c) +-- +-- Not built yet, and absent rather than borrowed from Platinum: marts, +-- berries, trades, shadows, honey trees. + +local C = {} + +C.HM_TAIL = { 15, 19, 57, 70, 250, 249, 127, 431 } +C.NUM_TMS, C.NUM_HMS = 92, 8 +C.BADGES = { "ZEPHYR", "HIVE", "PLAIN", "FOG", "STORM", "MINERAL", "GLACIER", "RISING", + "BOULDER", "CASCADE", "THUNDER", "RAINBOW", "SOUL", "MARSH", "VOLCANO", "EARTH" } +C.GROWTH = { "MEDIUM_FAST", "ERRATIC", "FLUCTUATING", "MEDIUM_SLOW", "FAST", "SLOW" } + +local function tmhm(arm9) + local needle = {} + for _, v in ipairs(C.HM_TAIL) do needle[#needle + 1] = string.char(v % 256, math.floor(v / 256)) end + needle = table.concat(needle) + local p = arm9:find(needle, 1, true) + while p do + local start = p - C.NUM_TMS * 2 + if start >= 1 then + local list, seen, good = {}, {}, true + for i = 0, C.NUM_TMS + C.NUM_HMS - 1 do + local a, b = arm9:byte(start + i * 2, start + i * 2 + 1) + local v = (a or 0) + (b or 0) * 256 + if v == 0 or seen[v] then good = false end + seen[v] = true + list[i + 1] = v + end + if good then return list, start - 1 end + end + p = arm9:find(needle, p + 1, true) + end + return nil +end + +-- build(x) -> constants, typeChart, report; `x` is the RomExtractorGen4 +function C.build(x) + local TypeChart = require("src.import.Gen4TypeChart") + local report = {} + local chart, parsed + for overlay = 0, 140 do + local ok, bytes = pcall(function() return x.rom:overlay(overlay) end) + if ok and bytes and #bytes > 0 then + local at = TypeChart.find(bytes) + parsed = at and TypeChart.parse(bytes, at) + if parsed then + parsed.overlay = overlay + chart = TypeChart.chart(parsed) + break + end + end + end + report.chartOverlay = parsed and parsed.overlay + + local BANK = require("src.import.RomExtractorGen4").BANK + local types, natures = {}, {} + for i = 0, TypeChart.TYPE_COUNT - 1 do types[i] = x:string(BANK.typeName, i) or TypeChart.TYPES[i] end + for i = 0, 24 do natures[i + 1] = x:string(BANK.nature, i) end + + local curves + local growth = x:archive("growthTables") + if growth then + curves = {} + for i, name in ipairs(C.GROWTH) do + local m = growth:get(i - 1) + if m and #m >= 404 then + local curve = {} + for level = 0, 100 do + local a, b, c, d = m:byte(level * 4 + 1, level * 4 + 4) + curve[level + 1] = a + b * 256 + c * 65536 + d * 16777216 + end + curves[name] = curve + end + end + end + + local arm9 = x.rom:arm9() + local tms, tmAt = tmhm(arm9) + report.tmhmAt = tmAt + + -- the new game's world state, out of the init band's script + local newGame + do + local Bands = require("src.import.Gen4ScriptBands") + local Script = require("src.import.Gen4Script") + local VM = require("src.script.Gen4ScriptVM") + local Ops = require("src.import.Gen4HgssScriptOps") + local Aliases = require("src.import.Gen4HgssScriptAliases") + local member + for _, band in ipairs(Bands.HGSS.BANDS) do if band[2] == "init" then member = band[3] end end + local arc = member and x:archive("scripts") + local bytes = arc and arc:get(member) + local entries = bytes and Script.entries(bytes) + if entries and entries[1] then + local flags, cleared, vars, seen, queue = {}, {}, {}, { [entries[1]] = true }, { entries[1] } + local i = 1 + while i <= #queue do + local at = queue[i]; i = i + 1 + for _, ins in ipairs((Script.decode(bytes, at, Ops))) do + local t = ins.target + if t and t >= 1 and t <= #bytes and not seen[t] then seen[t] = true; queue[#queue + 1] = t end + Aliases.apply(ins) + if ins.name == "setflag" then flags[#flags + 1] = VM.flagName(ins.args[1]) + elseif ins.name == "clearflag" then cleared[#cleared + 1] = VM.flagName(ins.args[1]) + elseif ins.name == "setvarfromvalue" then vars[#vars + 1] = { id = ins.args[1], value = ins.args[2] } end + end + end + newGame = { flags = flags, cleared = cleared, vars = vars, member = member, + source = ("ROM:scr_seq %d entry 0, %d SetFlag / %d ClearFlag / %d SetVar") + :format(member, #flags, #cleared, #vars) } + end + end + + local function order(n) local t = {} for i = 1, n do t[i] = i end return t end + local badges = {} + for i, id in ipairs(C.BADGES) do badges[i] = { id = "BADGE_" .. id } end + + -- IN-GAME TRADES: /a/1/1/2, one 0x54-byte record per trade (npc_trade.c + -- NPCTrade). Its first twenty u32s are Platinum's NPCTradeMon field for + -- field -- HGSS uses the two Platinum leaves unused for ability and sheen -- + -- so Gen4Trades reads them unchanged. Thirteen trades (constants/ + -- npc_trade.h, Rocky's Onix first); bank 200 holds the nicknames and then + -- the OT names (NPC_TRADE_OT_NUM = id + 13). + local trades + do + local Gen4Trades = require("src.import.Gen4Trades") + local arc = x:archiveAt("/a/1/1/2") + local names + local bank = x:bank(200) + if bank then + names = {} + for i = 0, bank.count - 1 do names[i + 1] = x:string(200, i) end + end + trades = arc and Gen4Trades.all(arc, names, 13) or nil + if trades then trades.source = "ROM:/a/1/1/2 (13 records of 84 bytes) + bank 200" end + end + local constants = { + gen4Trades = trades, + gen = 4, generation = 4, family = "hgss", + experienceTables = curves, + tmhmMoves = tms, + badges = badges, + gen4NewGame = newGame, + gen4MessageWindow = { left = 2, top = 19, width = 27, height = 4, + source = "pokeheartgold: options_app.c / choose_starter_app.c / naming_screen.c (2, 19, 27, 4)" }, + gen4Screen = { width = 256, height = 192 }, + types = types, + typeCount = TypeChart.TYPE_COUNT, + natures = natures, + natureOrder = order(25), + speciesOrder = order(493), + world = { blockPx = 16, stepFrames = 8, runStepFrames = 4 }, + source = ("ROM:%s (types bank %d, natures bank %d, type chart overlay %s)") + :format(x.version, BANK.typeName, BANK.nature, tostring(report.chartOverlay)), + } + report.newGameFlags = newGame and #newGame.flags or 0 + return constants, chart, report +end + +return C diff --git a/src/import/Gen4HgssFacings.lua b/src/import/Gen4HgssFacings.lua new file mode 100644 index 000000000..87ebffcf9 --- /dev/null +++ b/src/import/Gen4HgssFacings.lua @@ -0,0 +1,49 @@ +-- HEARTGOLD AND SOULSILVER'S ANIMATION SEQUENCE TABLES, by name. +-- +-- Platinum stores its twenty-five at the END of mmodel.narc and +-- Gen4Facings.read walks them back from there. HGSS's are seventeen members in +-- the MIDDLE -- 280..296 of /a/0/8/1, after the fourteen NSBMD models at +-- 266..279 and before the walking-partner sheets -- and pret names none of them +-- (files/data/mmodel/mmodel_00000280.json onward). +-- +-- THE NAMES ARE PROVEN BY IDENTITY, not guessed: each member below is BYTE FOR +-- BYTE the Platinum table of that name (tools/gen4_hgss_import_check.lua +-- re-reads both cartridges and compares). The two games inherited them from +-- Diamond and Pearl. Members with no Platinum twin keep a neutral name. +-- (285 repeats `surf` and 286 repeats `generic_walk`; the first of each is +-- the one named.) + +local Facings = require("src.import.Gen4Facings") + +local F = {} + +F.FIRST, F.LAST = 280, 296 +F.NAMES = { + [280] = "generic_walk", [281] = "walk_and_run", [282] = "bike", [283] = "surf", + [284] = "spray_duck", [285] = "hgss_285", [286] = "hgss_286", [287] = "hgss_287", + [288] = "pokecenter_nurse", [289] = "poketch", [290] = "save", [291] = "heal_pokecenter", + [292] = "hgss_292", [293] = "hgss_293", [294] = "hgss_294", [295] = "hgss_295", + [296] = "hgss_296", +} + +-- read(arc) -> byName, report: the same shape as Gen4Facings.read +function F.read(arc) + local byName, report = {}, { found = 0 } + if not arc or not arc.count then return byName, report end + for member = F.FIRST, math.min(F.LAST, arc.count - 1) do + local seq, why = Facings.parse(arc:get(member)) + if seq then + seq.member = member + seq.name = F.NAMES[member] + byName[seq.name] = seq + report.found = report.found + 1 + else + report.refused = report.refused or {} + report.refused[member] = why + end + end + report.complete = report.found == F.LAST - F.FIRST + 1 + return byName, report +end + +return F diff --git a/src/import/Gen4HgssField.lua b/src/import/Gen4HgssField.lua new file mode 100644 index 000000000..78de65524 --- /dev/null +++ b/src/import/Gen4HgssField.lua @@ -0,0 +1,159 @@ +-- WHERE A HEARTGOLD / SOULSILVER GAME STARTS, WHERE A BLACKOUT SENDS YOU, AND +-- WHICH SHEETS THE PLAYER WEARS. +-- +-- The same two things Gen4Field finds for Platinum, in HGSS's own shapes: +-- +-- sLocation_PlayerRoom, then sLocation_OutsidePlayerHome (src/location_backup.c) +-- Location = { s32 mapId, s32 warpId (-1), s32 x, s32 y, s32 direction } +-- -- the player's bedroom (NEW_BARK_PLAYER_HOUSE_2F, 6, 6, facing down) +-- and the tile outside the front door +-- +-- sSpawnMaps (asm/unk_0203BA5C.s), 18 bytes a row, not Platinum's 16: +-- u16 flagIdx:8 isBlackoutSpawn:1 isFlyPoint:1 u16 deathSpawnMap +-- u8 deathX, u8 deathY u16 flyMap, flyX, flyY +-- u16 specialWarpMap, specialX, specialY +-- +-- BOTH FOUND BY SIGNATURE in the decompressed ARM9, and tied together the way +-- Gen4Field ties Platinum's: the location after the start IS the first spawn +-- row's fly point (New Bark Town, outside the player's house), so two tables +-- have to agree before either is believed. pret's values are what +-- tools/gen4_hgss_import_check.lua expects to come back. +-- +-- THE PLAYER'S SHEETS come from PlayerAvatar's state switch (src/player_avatar.c): +-- walking HERO, cycling CYCLEHERO, surfing SWIMHERO, using an HM SPHERO, +-- fishing FISHINGHERO, and the HEROINE of each. + +local F = {} + +F.SPAWN_BYTES = 18 +F.LOCATION_BYTES = 20 +F.MIN_SPAWN_ROWS = 20 +F.WARP_NONE = 0xFFFFFFFF + +-- sprite ids (pokeheartgold include/constants/sprites.h) +F.PLAYER = { + boy = { label = "BOY", walk = 0, bike = 21, surf = 178, fieldMove = 176, fishing = 188 }, + girl = { label = "GIRL", walk = 97, bike = 98, surf = 179, fieldMove = 177, fishing = 189 }, +} + +local function u16(s, at) local a, b = s:byte(at + 1, at + 2); return b and a + b * 256 end +local function u32(s, at) local lo, hi = u16(s, at), u16(s, at + 2); return hi and lo + hi * 65536 end + +local function row(s, at) + local flags = u16(s, at) + if not flags then return nil end + return { + flagIdx = flags % 256, + isBlackoutSpawn = math.floor(flags / 256) % 2 == 1, + isFlyPoint = math.floor(flags / 512) % 2 == 1, + high = math.floor(flags / 1024), + blackOutMap = u16(s, at + 2), blackOutX = s:byte(at + 5), blackOutZ = s:byte(at + 6), + flyMap = u16(s, at + 6), flyX = u16(s, at + 8), flyZ = u16(s, at + 10), + specialMap = u16(s, at + 12), specialX = u16(s, at + 14), specialZ = u16(s, at + 16), + } +end + +local function plausible(r, count) + return r and r.high == 0 and r.blackOutMap and r.blackOutMap > 0 and r.blackOutMap < count + and r.flyMap and r.flyMap > 0 and r.flyMap < count + and r.specialMap and r.specialMap > 0 and r.specialMap < count +end + +-- spawns(arm9, headerCount) -> rows, offset +function F.spawns(arm9, count) + local bestAt, bestRows = nil, 0 + local at = 0 + while at <= #arm9 - F.SPAWN_BYTES do + if plausible(row(arm9, at), count) then + local n = 0 + while plausible(row(arm9, at + n * F.SPAWN_BYTES), count) do n = n + 1 end + if n > bestRows then bestAt, bestRows = at, n end + at = at + math.max(n, 1) * F.SPAWN_BYTES + else + at = at + 2 + end + end + if not bestAt or bestRows < F.MIN_SPAWN_ROWS then return nil end + local out = {} + for i = 0, bestRows - 1 do out[i + 1] = row(arm9, bestAt + i * F.SPAWN_BYTES) end + return out, bestAt +end + +local function location(s, at) + local map, warp = u32(s, at), u32(s, at + 4) + if not warp then return nil end + return { mapHeaderID = map, warpId = warp, x = u32(s, at + 8), z = u32(s, at + 12), + facing = u32(s, at + 16) } +end + +-- the run of plausible rows starting exactly at `at` +local function runAt(arm9, at, count) + local n = 0 + while plausible(row(arm9, at + n * F.SPAWN_BYTES), count) do n = n + 1 end + return n +end + +-- find(arm9, headerCount) -> start, respawn, locationOffset, spawns, spawnOffset +-- +-- THE LOCATION PAIR FIRST, THEN THE TABLE THAT AGREES WITH IT. Scanning for +-- the longest run of plausible spawn rows on its own finds 56 "rows" of +-- something else (0, 1, 2, 3 ... counting up) ahead of the real 27, because an +-- 18-byte row of small numbers is a weak shape. So each candidate pair is +-- asked for a table whose FIRST row flies to the pair's second location, and +-- exactly one pair, with exactly one such table, has to answer. +function F.find(arm9, count) + if type(arm9) ~= "string" then return nil, "no arm9" end + local matches = {} + for at = 0, #arm9 - F.LOCATION_BYTES * 2, 4 do + local start = location(arm9, at) + if start and start.warpId == F.WARP_NONE and start.mapHeaderID > 0 and start.mapHeaderID < count + and start.facing <= 3 then + local after = location(arm9, at + F.LOCATION_BYTES) + if after and after.warpId == F.WARP_NONE and after.mapHeaderID > 0 and after.mapHeaderID < count + and after.facing <= 3 and after.mapHeaderID ~= start.mapHeaderID then + -- the spawn row whose fly point is `after`: u16 map, x, y at +6 + local needle = string.char(after.mapHeaderID % 256, math.floor(after.mapHeaderID / 256), + after.x % 256, math.floor(after.x / 256) % 256, after.z % 256, math.floor(after.z / 256) % 256) + local from = 1 + while true do + local i = arm9:find(needle, from, true) + if not i then break end + local rowAt = i - 1 - 6 + if rowAt >= 0 then + local n = runAt(arm9, rowAt, count) + if n >= F.MIN_SPAWN_ROWS then + matches[#matches + 1] = { at = at, start = start, respawn = after, spawnAt = rowAt, rows = n } + end + end + from = i + 1 + end + end + end + end + if #matches ~= 1 then + return nil, ("expected one start/outside pair with its spawn table, found %d"):format(#matches) + end + local m = matches[1] + local spawns = {} + for i = 0, m.rows - 1 do spawns[i + 1] = row(arm9, m.spawnAt + i * F.SPAWN_BYTES) end + return m.start, m.respawn, m.at, spawns, m.spawnAt +end + +-- playerSheets(spriteFor) -> playerSprites, playerForms, in Platinum's shape +function F.playerSheets(spriteFor) + local forms = { order = {} } + for _, key in ipairs({ "boy", "girl" }) do + local ids = F.PLAYER[key] + local form = { label = ids.label } + for _, state in ipairs({ "walk", "bike", "surf", "fieldMove", "fishing" }) do + form[state] = spriteFor(ids[state]) + end + if form.walk then forms.order[#forms.order + 1] = key; forms[key] = form end + end + local default = forms.boy or forms.girl + if not default then return nil, nil end + return { walk = default.walk, bike = default.bike, surf = default.surf, + fieldMove = default.fieldMove, fishing = default.fishing }, forms +end + +return F diff --git a/src/import/Gen4HgssFiles.lua b/src/import/Gen4HgssFiles.lua new file mode 100644 index 000000000..bd4a49a83 --- /dev/null +++ b/src/import/Gen4HgssFiles.lua @@ -0,0 +1,345 @@ +-- GENERATED by tools/gen4_hgss_files_gen.py from pret/pokeheartgold +-- include/filesystem_files_def.h: every numbered archive of HeartGold and +-- SoulSilver, by NarcId, with its ROM path and -- where the decomp has named +-- it -- that name. Do not edit; regenerate. +return { + count = 267, + byId = { + [0] = { path = "/a/0/0/0", name = "a_0_0_0" }, + [1] = { path = "/a/0/0/1", name = "a_0_0_1" }, + [2] = { path = "/a/0/0/2", name = "poketool_personal_personal" }, + [3] = { path = "/a/0/0/3", name = "poketool_personal_growtbl" }, + [4] = { path = "/a/0/0/4", name = "poketool_pokegra_pokegra" }, + [5] = { path = "/a/0/0/5", name = "poketool_pokegra_height" }, + [6] = { path = "/a/0/0/6", name = "a_0_0_6" }, + [7] = { path = "/a/0/0/7", name = "a_0_0_7" }, + [8] = { path = "/a/0/0/8", name = "a_0_0_8" }, + [9] = { path = "/a/0/0/9", name = "a_0_0_9" }, + [10] = { path = "/a/0/1/0", name = "a_0_1_0" }, + [11] = { path = "/a/0/1/1", name = "poketool_waza_waza_tbl" }, + [12] = { path = "/a/0/1/2", name = "fielddata_script_scr_seq" }, + [13] = { path = "/a/0/1/3", name = "a_0_1_3" }, + [14] = { path = "/a/0/1/4", name = "a_0_1_4" }, + [15] = { path = "/a/0/1/5", name = "a_0_1_5" }, + [16] = { path = "/a/0/1/6", name = "graphic_font" }, + [17] = { path = "/a/0/1/7", name = "itemtool_itemdata_item_data" }, + [18] = { path = "/a/0/1/8", name = "itemtool_itemdata_item_icon" }, + [19] = { path = "/a/0/1/9", name = "a_0_1_9" }, + [20] = { path = "/a/0/2/0", name = "poketool_icongra_poke_icon" }, + [21] = { path = "/a/0/2/1", name = "graphic_plist_gra" }, + [22] = { path = "/a/0/2/2", name = "a_0_2_2" }, + [23] = { path = "/a/0/2/3", name = "a_0_2_3" }, + [24] = { path = "/a/0/2/4", name = "a_0_2_4" }, + [25] = { path = "/a/0/2/5", name = "a_0_2_5" }, + [26] = { path = "/a/0/2/6", name = "a_0_2_6" }, + [27] = { path = "/a/0/2/7", name = "msgdata_msg" }, + [28] = { path = "/a/0/2/8", name = "a_0_2_8" }, + [29] = { path = "/a/0/2/9", name = "a_0_2_9" }, + [30] = { path = "/a/0/3/0", name = "a_0_3_0" }, + [31] = { path = "/a/0/3/1", name = "data_namein" }, + [32] = { path = "/a/0/3/2", name = "fielddata_eventdata_zone_event" }, + [33] = { path = "/a/0/3/3", name = "poketool_personal_wotbl" }, + [34] = { path = "/a/0/3/4", name = "poketool_personal_evo" }, + [35] = { path = "/a/0/3/5", name = "a_0_3_5" }, + [36] = { path = "/a/0/3/6", name = "a_0_3_6" }, + [37] = { path = "/a/0/3/7", name = "fielddata_encountdata_g_enc_data" }, + [38] = { path = "/a/0/3/8", name = "a_0_3_8" }, + [39] = { path = "/a/0/3/9", name = "a_0_3_9" }, + [40] = { path = "/a/0/4/0", name = "a_0_4_0" }, + [41] = { path = "/a/0/4/1", name = "fielddata_mapmatrix_map_matrix" }, + [42] = { path = "/a/0/4/2", name = "a_0_4_2" }, + [43] = { path = "/a/0/4/3", name = "a_0_4_3" }, + [44] = { path = "/a/0/4/4", name = "a_0_4_4" }, + [45] = { path = "/a/0/4/5", name = "a_0_4_5" }, + [46] = { path = "/a/0/4/6", name = "demo_title_titledemo" }, + [47] = { path = "/a/0/4/7", name = "a_0_4_7" }, + [48] = { path = "/a/0/4/8", name = "a_0_4_8" }, + [49] = { path = "/a/0/4/9", name = "a_0_4_9" }, + [50] = { path = "/a/0/5/0", name = "a_0_5_0" }, + [51] = { path = "/a/0/5/1", name = "a_0_5_1" }, + [52] = { path = "/a/0/5/2", name = "a_0_5_2" }, + [53] = { path = "/a/0/5/3", name = "a_0_5_3" }, + [54] = { path = "/a/0/5/4", name = "a_0_5_4" }, + [55] = { path = "/a/0/5/5", name = "poketool_trainer_trdata" }, + [56] = { path = "/a/0/5/6", name = "poketool_trainer_trpoke" }, + [57] = { path = "/a/0/5/7", name = "poketool_trmsg_trtbl" }, + [58] = { path = "/a/0/5/8", name = "a_0_5_8" }, + [59] = { path = "/a/0/5/9", name = "a_0_5_9" }, + [60] = { path = "/a/0/6/0", name = "a_0_6_0" }, + [61] = { path = "/a/0/6/1", name = "a_0_6_1" }, + [62] = { path = "/a/0/6/2", name = "a_0_6_2" }, + [63] = { path = "/a/0/6/3", name = "a_0_6_3" }, + [64] = { path = "/a/0/6/4", name = "a_0_6_4" }, + [65] = { path = "/a/0/6/5", name = "fielddata_landdata_land_data" }, + [66] = { path = "/a/0/6/6", name = "a_0_6_6" }, + [67] = { path = "/a/0/6/7", name = "a_0_6_7" }, + [68] = { path = "/a/0/6/8", name = "graphic_zukan_gra" }, + [69] = { path = "/a/0/6/9", name = "poketool_pokefoot_pokefoot" }, + [70] = { path = "/a/0/7/0", name = "a_0_7_0" }, + [71] = { path = "/a/0/7/1", name = "a_0_7_1" }, + [72] = { path = "/a/0/7/2", name = "a_0_7_2" }, + [73] = { path = "/a/0/7/3", name = "a_0_7_3" }, + [74] = { path = "/a/0/7/4", name = "application_zukanlist_zkn_data_zukan_data" }, + [75] = { path = "/a/0/7/5", name = "application_zukanlist_zkn_data_zukan_hw_data" }, + [76] = { path = "/a/0/7/6", name = "a_0_7_6" }, + [77] = { path = "/a/0/7/7", name = "a_0_7_7" }, + [78] = { path = "/a/0/7/8", name = "a_0_7_8" }, + [79] = { path = "/a/0/7/9", name = "a_0_7_9" }, + [80] = { path = "/a/0/8/0", name = "a_0_8_0" }, + [81] = { path = "/a/0/8/1", name = "data_mmodel_mmodel" }, + [82] = { path = "/a/0/8/2", name = "application_choose_starter_choose_starter_main_res" }, + [83] = { path = "/a/0/8/3", name = "a_0_8_3" }, + [84] = { path = "/a/0/8/4", name = "application_record_record" }, + [85] = { path = "/a/0/8/5", name = "a_0_8_5" }, + [86] = { path = "/a/0/8/6", name = "a_0_8_6" }, + [87] = { path = "/a/0/8/7", name = "a_0_8_7" }, + [88] = { path = "/a/0/8/8", name = "a_0_8_8" }, + [89] = { path = "/a/0/8/9", name = "a_0_8_9" }, + [90] = { path = "/a/0/9/0", name = "a_0_9_0" }, + [91] = { path = "/a/0/9/1", name = "a_0_9_1" }, + [92] = { path = "/a/0/9/2", name = "a_0_9_2" }, + [93] = { path = "/a/0/9/3", name = "application_choose_starter_choose_starter_sub_res" }, + [94] = { path = "/a/0/9/4", name = "a_0_9_4" }, + [95] = { path = "/a/0/9/5", name = "a_0_9_5" }, + [96] = { path = "/a/0/9/6", name = "a_0_9_6" }, + [97] = { path = "/a/0/9/7", name = "a_0_9_7" }, + [98] = { path = "/a/0/9/8", name = "a_0_9_8" }, + [99] = { path = "/a/0/9/9", name = "a_0_9_9" }, + [100] = { path = "/a/1/0/0", name = "a_1_0_0" }, + [101] = { path = "/a/1/0/1", name = "a_1_0_1" }, + [102] = { path = "/a/1/0/2", name = "a_1_0_2" }, + [103] = { path = "/a/1/0/3", name = "a_1_0_3" }, + [104] = { path = "/a/1/0/4", name = "a_1_0_4" }, + [105] = { path = "/a/1/0/5", name = "a_1_0_5" }, + [106] = { path = "/a/1/0/6", name = "a_1_0_6" }, + [107] = { path = "/a/1/0/7", name = "a_1_0_7" }, + [108] = { path = "/a/1/0/8", name = "a_1_0_8" }, + [109] = { path = "/a/1/0/9", name = "a_1_0_9" }, + [110] = { path = "/a/1/1/0", name = "a_1_1_0" }, + [111] = { path = "/a/1/1/1", name = "a_1_1_1" }, + [112] = { path = "/a/1/1/2", name = "a_1_1_2" }, + [113] = { path = "/a/1/1/3", name = "a_1_1_3" }, + [114] = { path = "/a/1/1/4", name = "poketool_pokegra_otherpoke" }, + [115] = { path = "/a/1/1/5", name = "a_1_1_5" }, + [116] = { path = "/a/1/1/6", name = "a_1_1_6" }, + [117] = { path = "/a/1/1/7", name = "poketool_pokegra_height_o" }, + [118] = { path = "/a/1/1/8", name = "a_1_1_8" }, + [119] = { path = "/a/1/1/9", name = "a_1_1_9" }, + [120] = { path = "/a/1/2/0", name = "demo_intro_intro" }, + [121] = { path = "/a/1/2/1", name = "a_1_2_1" }, + [122] = { path = "/a/1/2/2", name = "a_1_2_2" }, + [123] = { path = "/a/1/2/3", name = "a_1_2_3" }, + [124] = { path = "/a/1/2/4", name = "a_1_2_4" }, + [125] = { path = "/a/1/2/5", name = "arc_ppark" }, + [126] = { path = "/a/1/2/6", name = "a_1_2_6" }, + [127] = { path = "/a/1/2/7", name = "a_1_2_7" }, + [128] = { path = "/a/1/2/8", name = "a_1_2_8" }, + [129] = { path = "/a/1/2/9", name = "a_1_2_9" }, + [130] = { path = "/a/1/3/0", name = "a_1_3_0" }, + [131] = { path = "/a/1/3/1", name = "poketool_trmsg_trtblofs" }, + [132] = { path = "/a/1/3/2", name = "a_1_3_2" }, + [133] = { path = "/a/1/3/3", name = "application_zukanlist_zkn_data_zukan_enc" }, + [134] = { path = "/a/1/3/4", name = "a_1_3_4" }, + [135] = { path = "/a/1/3/5", name = "a_1_3_5" }, + [136] = { path = "/a/1/3/6", name = "fielddata_encountdata_s_enc_data" }, + [137] = { path = "/a/1/3/7", name = "a_1_3_7" }, + [138] = { path = "/a/1/3/8", name = "poketool_johtozukan" }, + [139] = { path = "/a/1/3/9", name = "a_1_3_9" }, + [140] = { path = "/a/1/4/0", name = "a_1_4_0" }, + [141] = { path = "/a/1/4/1", name = "fielddata_tsurepoke_tp_param" }, + [142] = { path = "/a/1/4/2", name = "a_1_4_2" }, + [143] = { path = "/a/1/4/3", name = "application_pokegear_pgear_gra" }, + [144] = { path = "/a/1/4/4", name = "application_pokegear_map_pgmap_gra" }, + [145] = { path = "/a/1/4/5", name = "application_pokegear_configure_pgconf_gra" }, + [146] = { path = "/a/1/4/6", name = "application_pokegear_phone_pgphone_gra" }, + [147] = { path = "/a/1/4/7", name = "application_pokegear_radio_pgradio_gra" }, + [148] = { path = "/a/1/4/8", name = "a_1_4_8" }, + [149] = { path = "/a/1/4/9", name = "a_1_4_9" }, + [150] = { path = "/a/1/5/0", name = "a_1_5_0" }, + [151] = { path = "/a/1/5/1", name = "a_1_5_1" }, + [152] = { path = "/a/1/5/2", name = "system_touch_subwindow" }, + [153] = { path = "/a/1/5/3", name = "a_1_5_3" }, + [154] = { path = "/a/1/5/4", name = "a_1_5_4" }, + [155] = { path = "/a/1/5/5", name = "a_1_5_5" }, + [156] = { path = "/a/1/5/6", name = "a_1_5_6" }, + [157] = { path = "/a/1/5/7", name = "a_1_5_7" }, + [158] = { path = "/a/1/5/8", name = "a_1_5_8" }, + [159] = { path = "/a/1/5/9", name = "a_1_5_9" }, + [160] = { path = "/a/1/6/0", name = "a_1_6_0" }, + [161] = { path = "/a/1/6/1", name = "poketool_pokezukan" }, + [162] = { path = "/a/1/6/2", name = "a_1_6_2" }, + [163] = { path = "/a/1/6/3", name = "data_gs_areawindow" }, + [164] = { path = "/a/1/6/4", name = "a_1_6_4" }, + [165] = { path = "/a/1/6/5", name = "data_sbox_gra" }, + [166] = { path = "/a/1/6/6", name = "a_1_6_6" }, + [167] = { path = "/a/1/6/7", name = "a_1_6_7" }, + [168] = { path = "/a/1/6/8", name = "a_1_6_8" }, + [169] = { path = "/a/1/6/9", name = "poketool_personal_performance" }, + [170] = { path = "/a/1/7/0", name = "a_1_7_0" }, + [171] = { path = "/a/1/7/1", name = "a_1_7_1" }, + [172] = { path = "/a/1/7/2", name = "application_annon_puzzle_gra" }, + [173] = { path = "/a/1/7/3", name = "a_1_7_3" }, + [174] = { path = "/a/1/7/4", name = "demo_legend" }, + [175] = { path = "/a/1/7/5", name = "data_resdat" }, + [176] = { path = "/a/1/7/6", name = "a_1_7_6" }, + [177] = { path = "/a/1/7/7", name = "a_1_7_7" }, + [178] = { path = "/a/1/7/8", name = "a_1_7_8" }, + [179] = { path = "/a/1/7/9", name = "a_1_7_9" }, + [180] = { path = "/a/1/8/0", name = "a_1_8_0" }, + [181] = { path = "/a/1/8/1", name = "a_1_8_1" }, + [182] = { path = "/a/1/8/2", name = "a_1_8_2" }, + [183] = { path = "/a/1/8/3", name = "a_1_8_3" }, + [184] = { path = "/a/1/8/4", name = "a_1_8_4" }, + [185] = { path = "/a/1/8/5", name = "application_custom_ball_edit_gs_cb_data" }, + [186] = { path = "/a/1/8/6", name = "a_1_8_6" }, + [187] = { path = "/a/1/8/7", name = "a_1_8_7" }, + [188] = { path = "/a/1/8/8", name = "a_1_8_8" }, + [189] = { path = "/a/1/8/9", name = "a_1_8_9" }, + [190] = { path = "/a/1/9/0", name = "a_1_9_0" }, + [191] = { path = "/a/1/9/1", name = "a_1_9_1" }, + [192] = { path = "/a/1/9/2", name = "a_1_9_2" }, + [193] = { path = "/a/1/9/3", name = "a_1_9_3" }, + [194] = { path = "/pbr/pokegra.narc", name = "pbr_pokegra" }, + [195] = { path = "/pbr/otherpoke.narc", name = "pbr_otherpoke" }, + [196] = { path = "/a/1/9/4", name = "pbr_dp_height" }, + [197] = { path = "/a/1/9/5", name = "pbr_dp_height_o" }, + [198] = { path = "/a/1/9/6", name = "a_1_9_6" }, + [199] = { path = "/a/1/9/7", name = "a_1_9_7" }, + [200] = { path = "/a/1/9/8", name = "a_1_9_8" }, + [201] = { path = "/a/1/9/9", name = "a_1_9_9" }, + [202] = { path = "/a/2/0/0", name = "a_2_0_0" }, + [203] = { path = "/a/2/0/1", name = "a_2_0_1" }, + [204] = { path = "/a/2/0/2", name = "a_2_0_2" }, + [205] = { path = "/a/2/0/3", name = "a_2_0_3" }, + [206] = { path = "/a/2/0/4", name = "a_2_0_4" }, + [207] = { path = "/a/2/0/5", name = "a_2_0_5" }, + [208] = { path = "/a/2/0/6", name = "a_2_0_6" }, + [209] = { path = "/a/2/0/7", name = "a_2_0_7" }, + [210] = { path = "/a/2/0/8", name = "a_2_0_8" }, + [211] = { path = "/a/2/0/9", name = "a_2_0_9" }, + [212] = { path = "/a/2/1/0", name = "a_2_1_0" }, + [213] = { path = "/a/2/1/1", name = "a_2_1_1" }, + [214] = { path = "/a/2/1/2", name = "resource_eng_pms_aikotoba_pms_aikotoba" }, + [215] = { path = "/a/2/1/3", name = "a_2_1_3" }, + [216] = { path = "/a/2/1/4", name = "application_zukanlist_zkn_data_zukan_data_gira" }, + [217] = { path = "/a/2/1/5", name = "a_2_1_5" }, + [218] = { path = "/a/2/1/6", name = "a_2_1_6" }, + [219] = { path = "/a/2/1/7", name = "a_2_1_7" }, + [220] = { path = "/a/2/1/8", name = "a_2_1_8" }, + [221] = { path = "/a/2/1/9", name = "a_2_1_9" }, + [222] = { path = "/a/2/2/0", name = "a_2_2_0" }, + [223] = { path = "/a/2/2/1", name = "a_2_2_1" }, + [224] = { path = "/a/2/2/2", name = "a_2_2_2" }, + [225] = { path = "/a/2/2/3", name = "a_2_2_3" }, + [226] = { path = "/a/2/2/4", name = "a_2_2_4" }, + [227] = { path = "/a/2/2/5", name = "a_2_2_5" }, + [228] = { path = "/a/2/2/6", name = "a_2_2_6" }, + [229] = { path = "/a/2/2/7", name = "a_2_2_7" }, + [230] = { path = "/a/2/2/8", name = "a_2_2_8" }, + [231] = { path = "/a/2/2/9", name = "fielddata_breeding_egg_move_list" }, + [232] = { path = "/a/2/3/0", name = "a_2_3_0" }, + [233] = { path = "/a/2/3/1", name = "a_2_3_1" }, + [234] = { path = "/a/2/3/2", name = "a_2_3_2" }, + [235] = { path = "/a/2/3/3", name = "a_2_3_3" }, + [236] = { path = "/a/2/3/4", name = "a_2_3_4" }, + [237] = { path = "/a/2/3/5", name = "a_2_3_5" }, + [238] = { path = "/a/2/3/6", name = "a_2_3_6" }, + [239] = { path = "/a/2/3/7", name = "a_2_3_7" }, + [240] = { path = "/a/2/3/8", name = "a_2_3_8" }, + [241] = { path = "/a/2/3/9", name = "a_2_3_9" }, + [242] = { path = "/a/2/4/0", name = "a_2_4_0" }, + [243] = { path = "/a/2/4/1", name = "a_2_4_1" }, + [244] = { path = "/a/2/4/2", name = "a_2_4_2" }, + [245] = { path = "/a/2/4/3", name = "a_2_4_3" }, + [246] = { path = "/a/2/4/4", name = "a_2_4_4" }, + [247] = { path = "/a/2/4/5", name = "a_2_4_5" }, + [248] = { path = "/a/2/4/6", name = "a_2_4_6" }, + [249] = { path = "/a/2/4/7", name = "a_2_4_7" }, + [250] = { path = "/a/2/4/8", name = "a_2_4_8" }, + [251] = { path = "/a/2/4/9", name = "a_2_4_9" }, + [252] = { path = "/a/2/5/0", name = "a_2_5_0" }, + [253] = { path = "/a/2/5/1", name = "a_2_5_1" }, + [254] = { path = "/a/2/5/2", name = "arc_headbutt" }, + [255] = { path = "/a/2/5/3", name = "a_2_5_3" }, + [256] = { path = "/a/2/5/4", name = "data_photo_data" }, + [257] = { path = "/a/2/5/5", name = "a_2_5_5" }, + [258] = { path = "/a/2/5/6", name = "a_2_5_6" }, + [259] = { path = "/a/2/5/7", name = "a_2_5_7" }, + [260] = { path = "/a/2/5/8", name = "a_2_5_8" }, + [261] = { path = "/a/2/5/9", name = "a_2_5_9" }, + [262] = { path = "/a/2/6/0", name = "application_guinness" }, + [263] = { path = "/a/2/6/1", name = "graphic_camera_viewfinder" }, + [264] = { path = "/a/2/6/2", name = "demo_opening_gs_opening" }, + [265] = { path = "/a/2/6/3", name = "a_2_6_3" }, + [266] = { path = "/a/2/6/4", name = "application_voltorb_flip" }, + }, + byName = { + poketool_personal_personal = "/a/0/0/2", + poketool_personal_growtbl = "/a/0/0/3", + poketool_pokegra_pokegra = "/a/0/0/4", + poketool_pokegra_height = "/a/0/0/5", + poketool_waza_waza_tbl = "/a/0/1/1", + fielddata_script_scr_seq = "/a/0/1/2", + graphic_font = "/a/0/1/6", + itemtool_itemdata_item_data = "/a/0/1/7", + itemtool_itemdata_item_icon = "/a/0/1/8", + poketool_icongra_poke_icon = "/a/0/2/0", + graphic_plist_gra = "/a/0/2/1", + msgdata_msg = "/a/0/2/7", + data_namein = "/a/0/3/1", + fielddata_eventdata_zone_event = "/a/0/3/2", + poketool_personal_wotbl = "/a/0/3/3", + poketool_personal_evo = "/a/0/3/4", + fielddata_encountdata_g_enc_data = "/a/0/3/7", + fielddata_mapmatrix_map_matrix = "/a/0/4/1", + demo_title_titledemo = "/a/0/4/6", + poketool_trainer_trdata = "/a/0/5/5", + poketool_trainer_trpoke = "/a/0/5/6", + poketool_trmsg_trtbl = "/a/0/5/7", + fielddata_landdata_land_data = "/a/0/6/5", + graphic_zukan_gra = "/a/0/6/8", + poketool_pokefoot_pokefoot = "/a/0/6/9", + application_zukanlist_zkn_data_zukan_data = "/a/0/7/4", + application_zukanlist_zkn_data_zukan_hw_data = "/a/0/7/5", + data_mmodel_mmodel = "/a/0/8/1", + application_choose_starter_choose_starter_main_res = "/a/0/8/2", + application_record_record = "/a/0/8/4", + application_choose_starter_choose_starter_sub_res = "/a/0/9/3", + poketool_pokegra_otherpoke = "/a/1/1/4", + poketool_pokegra_height_o = "/a/1/1/7", + demo_intro_intro = "/a/1/2/0", + arc_ppark = "/a/1/2/5", + poketool_trmsg_trtblofs = "/a/1/3/1", + application_zukanlist_zkn_data_zukan_enc = "/a/1/3/3", + fielddata_encountdata_s_enc_data = "/a/1/3/6", + poketool_johtozukan = "/a/1/3/8", + fielddata_tsurepoke_tp_param = "/a/1/4/1", + application_pokegear_pgear_gra = "/a/1/4/3", + application_pokegear_map_pgmap_gra = "/a/1/4/4", + application_pokegear_configure_pgconf_gra = "/a/1/4/5", + application_pokegear_phone_pgphone_gra = "/a/1/4/6", + application_pokegear_radio_pgradio_gra = "/a/1/4/7", + system_touch_subwindow = "/a/1/5/2", + poketool_pokezukan = "/a/1/6/1", + data_gs_areawindow = "/a/1/6/3", + data_sbox_gra = "/a/1/6/5", + poketool_personal_performance = "/a/1/6/9", + application_annon_puzzle_gra = "/a/1/7/2", + demo_legend = "/a/1/7/4", + data_resdat = "/a/1/7/5", + application_custom_ball_edit_gs_cb_data = "/a/1/8/5", + pbr_pokegra = "/pbr/pokegra.narc", + pbr_otherpoke = "/pbr/otherpoke.narc", + pbr_dp_height = "/a/1/9/4", + pbr_dp_height_o = "/a/1/9/5", + resource_eng_pms_aikotoba_pms_aikotoba = "/a/2/1/2", + application_zukanlist_zkn_data_zukan_data_gira = "/a/2/1/4", + fielddata_breeding_egg_move_list = "/a/2/2/9", + arc_headbutt = "/a/2/5/2", + data_photo_data = "/a/2/5/4", + application_guinness = "/a/2/6/0", + graphic_camera_viewfinder = "/a/2/6/1", + demo_opening_gs_opening = "/a/2/6/2", + application_voltorb_flip = "/a/2/6/4", + }, +} diff --git a/src/import/Gen4HgssObjectGfx.lua b/src/import/Gen4HgssObjectGfx.lua new file mode 100644 index 000000000..24d97c682 --- /dev/null +++ b/src/import/Gen4HgssObjectGfx.lua @@ -0,0 +1,909 @@ +-- GENERATED by tools/gen4_hgss_sprites_gen.py from pret/pokeheartgold +-- asm/overlay_01_sprite_data.s (ov01_022074A8) with sprites.h and mmodel.h. +-- Do not edit; regenerate. byId[sprite] = { member, name, bits, anim }. +return { + count = 901, + byId = { + [1] = { 0, "babyboy1", 0x000, 0 }, + [2] = { 1, "babygirl1", 0x000, 0 }, + [3] = { 2, "boy1", 0x000, 0 }, + [4] = { 3, "boy2", 0x000, 0 }, + [5] = { 4, "boy3", 0x000, 0 }, + [6] = { 5, "girl1", 0x000, 0 }, + [7] = { 6, "girl2", 0x000, 0 }, + [8] = { 7, "girl3", 0x000, 0 }, + [9] = { 8, "man1", 0x000, 0 }, + [10] = { 9, "man2", 0x000, 0 }, + [11] = { 10, "man3", 0x000, 0 }, + [12] = { 12, "woman1", 0x000, 0 }, + [13] = { 13, "woman2", 0x000, 0 }, + [14] = { 14, "woman3", 0x000, 0 }, + [15] = { 16, "middleman1", 0x000, 0 }, + [16] = { 17, "middlewoman1", 0x000, 0 }, + [17] = { 18, "oldman1", 0x000, 0 }, + [18] = { 19, "oldwoman1", 0x000, 0 }, + [19] = { 34, "bigman", 0x000, 0 }, + [20] = { 35, "mount", 0x000, 0 }, + [21] = { 71, "cyclehero", 0x081, 6 }, + [22] = { 31, "reporter", 0x000, 0 }, + [23] = { 32, "cameraman", 0x000, 0 }, + [24] = { 23, "shopm1", 0x000, 0 }, + [25] = { 24, "shopw1", 0x000, 0 }, + [29] = { 25, "assistantm", 0x000, 0 }, + [30] = { 26, "assistantw", 0x000, 0 }, + [31] = { 36, "badman", 0x000, 0 }, + [33] = { 22, "skierw", 0x000, 0 }, + [34] = { 37, "policeman", 0x000, 0 }, + [35] = { 38, "idol", 0x000, 0 }, + [36] = { 39, "gentleman", 0x000, 0 }, + [37] = { 40, "lady", 0x000, 0 }, + [38] = { 20, "cyclem", 0x001, 0 }, + [39] = { 21, "cyclew", 0x001, 0 }, + [40] = { 41, "workman", 0x000, 0 }, + [41] = { 42, "farmer", 0x000, 0 }, + [42] = { 43, "cowgirl", 0x000, 0 }, + [43] = { 44, "clown", 0x000, 0 }, + [44] = { 45, "artist", 0x000, 0 }, + [45] = { 46, "sportsman", 0x000, 0 }, + [50] = { 47, "explore", 0x000, 0 }, + [51] = { 48, "fighter", 0x000, 0 }, + [52] = { 49, "campboy", 0x000, 0 }, + [53] = { 50, "picnicgirl", 0x000, 0 }, + [54] = { 51, "fishing", 0x000, 0 }, + [55] = { 52, "ambrella", 0x000, 0 }, + [56] = { 53, "seaman", 0x000, 0 }, + [59] = { 29, "waiter", 0x000, 0 }, + [60] = { 30, "waitress", 0x000, 0 }, + [62] = { 27, "gorggeousm", 0x000, 0 }, + [63] = { 28, "gorggeousw", 0x000, 0 }, + [68] = { 11, "man5", 0x000, 0 }, + [69] = { 15, "woman5", 0x000, 0 }, + [70] = { 33, "mystery", 0x000, 0 }, + [71] = { 68, "pikachu", 0x000, 0 }, + [84] = { 91, "rock", 0x020, 1 }, + [85] = { 92, "breakrock", 0x020, 2 }, + [86] = { 93, "tree", 0x020, 0 }, + [87] = { 94, "monstarball", 0x020, 1 }, + [97] = { 70, "heroine", 0x060, 7 }, + [98] = { 72, "cycleheroine", 0x081, 6 }, + [99] = { 54, "doctor", 0x000, 0 }, + [141] = { 62, "seven1", 0x000, 0 }, + [142] = { 63, "seven2", 0x000, 0 }, + [143] = { 64, "seven3", 0x000, 0 }, + [144] = { 65, "seven4", 0x000, 0 }, + [145] = { 66, "seven5", 0x000, 0 }, + [146] = { 67, "gsboy1", 0x000, 0 }, + [148] = { 58, "gsrivel", 0x000, 0 }, + [168] = { 57, "sunglasses", 0x000, 0 }, + [169] = { 61, "towerboss", 0x000, 0 }, + [175] = { 55, "maid", 0x000, 0 }, + [176] = { 75, "sphero", 0x0A0, 8 }, + [177] = { 76, "spheroine", 0x0A0, 8 }, + [180] = { 77, "waterhero", 0x0C0, 10 }, + [181] = { 78, "waterheroine", 0x0C0, 10 }, + [178] = { 73, "swimhero", 0x0E0, 9 }, + [179] = { 74, "swimheroine", 0x0E0, 9 }, + [183] = { 0, "babyboy1_2", 0x1E2, 63 }, + [188] = { 79, "fishinghero", 0x100, 11 }, + [189] = { 80, "fish_heroine", 0x100, 11 }, + [248] = { 81, "ladderhero", 0x120, 12 }, + [249] = { 82, "ladderheroine", 0x120, 12 }, + [193] = { 56, "delivery", 0x000, 0 }, + [8192] = { 0, "camera_focus", 0x003, 63 }, + [196] = { 85, "pokehero", 0x160, 15 }, + [197] = { 86, "pokeheroine", 0x160, 15 }, + [198] = { 87, "savehero", 0x0A0, 16 }, + [199] = { 88, "saveheroine", 0x0A0, 16 }, + [200] = { 89, "banzaihero", 0x140, 13 }, + [201] = { 90, "banzaiheroine", 0x140, 13 }, + [211] = { 170, "red", 0x000, 0 }, + [218] = { 171, "thief", 0x000, 0 }, + [219] = { 83, "itako", 0x000, 0 }, + [220] = { 172, "bozu", 0x000, 0 }, + [221] = { 173, "rocketw", 0x000, 0 }, + [222] = { 174, "rocketm", 0x000, 0 }, + [223] = { 175, "rkanbuw", 0x000, 0 }, + [224] = { 176, "rkanbum", 0x000, 0 }, + [293] = { 244, "rkanbum2", 0x000, 0 }, + [294] = { 245, "rkanbum3", 0x000, 0 }, + [225] = { 177, "boarder", 0x000, 0 }, + [227] = { 178, "fire", 0x000, 0 }, + [229] = { 179, "gang", 0x000, 0 }, + [232] = { 180, "dancer", 0x000, 0 }, + [235] = { 181, "masaki", 0x000, 0 }, + [236] = { 182, "captain", 0x000, 0 }, + [237] = { 183, "rgyaradosu", 0x000, 0 }, + [238] = { 213, "hero_2", 0x060, 7 }, + [239] = { 214, "heroine_2", 0x060, 7 }, + [250] = { 84, "itako_", 0x000, 3 }, + [251] = { 0, "gate_right", 0x1E4, 63 }, + [252] = { 0, "gate_left", 0x1E4, 63 }, + [253] = { 0, "babyboy1_5", 0x1E4, 63 }, + [254] = { 0, "gate_top", 0x1E4, 63 }, + [255] = { 0, "gate_bottom", 0x1E4, 63 }, + [256] = { 0, "babyboy1_8", 0x1E4, 63 }, + [257] = { 0, "babyboy1_9", 0x1E4, 63 }, + [288] = { 0, "babyboy1_10", 0x1E4, 63 }, + [289] = { 0, "babyboy1_11", 0x1E4, 63 }, + [290] = { 112, "leag_door2", 0x025, 4 }, + [291] = { 0, "babyboy1_12", 0x1E4, 63 }, + [292] = { 0, "babyboy1_13", 0x1E4, 63 }, + [295] = { 246, "pl_boy01c", 0x060, 7 }, + [296] = { 247, "pl_girl01c", 0x060, 7 }, + [315] = { 113, "gsbabyboy1", 0x000, 0 }, + [316] = { 114, "gsbabygirl1", 0x000, 0 }, + [317] = { 115, "gsboy2", 0x000, 0 }, + [318] = { 116, "gsboy3", 0x000, 0 }, + [319] = { 117, "gsgirl1", 0x000, 0 }, + [320] = { 118, "gsgirl2", 0x000, 0 }, + [321] = { 119, "gsgirl3", 0x000, 0 }, + [322] = { 120, "gsman1", 0x000, 0 }, + [323] = { 121, "gsman2", 0x000, 0 }, + [324] = { 122, "gsman3", 0x000, 0 }, + [325] = { 123, "gswoman1", 0x000, 0 }, + [326] = { 124, "gswoman2", 0x000, 0 }, + [327] = { 125, "gswoman3", 0x000, 0 }, + [328] = { 126, "gsmiddleman1", 0x000, 0 }, + [329] = { 127, "middlewoman1_2", 0x000, 0 }, + [330] = { 128, "gsoldman1", 0x000, 0 }, + [331] = { 129, "gsoldwoman1", 0x000, 0 }, + [332] = { 130, "gsbigman", 0x000, 0 }, + [333] = { 131, "mount_2", 0x000, 0 }, + [334] = { 132, "shopm1_2", 0x000, 0 }, + [335] = { 133, "pcwoman1", 0x180, 5 }, + [336] = { 134, "pcwoman2", 0x000, 0 }, + [337] = { 135, "pcwoman3", 0x000, 0 }, + [338] = { 136, "gsassistantm", 0x000, 0 }, + [341] = { 137, "gsgentleman", 0x000, 0 }, + [342] = { 138, "gsswimmerm", 0x000, 0 }, + [343] = { 139, "gsswimmerw", 0x000, 0 }, + [344] = { 140, "gsfighter", 0x000, 0 }, + [345] = { 141, "campboy_2", 0x000, 0 }, + [346] = { 142, "picnicgirl_2", 0x000, 0 }, + [347] = { 143, "fishing_2", 0x000, 0 }, + [348] = { 144, "seaman_2", 0x000, 0 }, + [351] = { 145, "mystery_2", 0x000, 0 }, + [352] = { 146, "gsleader1", 0x000, 0 }, + [353] = { 147, "gsleader2", 0x000, 0 }, + [354] = { 148, "gsleader3", 0x000, 0 }, + [355] = { 149, "gsleader4", 0x000, 0 }, + [356] = { 150, "gsleader5", 0x000, 0 }, + [357] = { 151, "gsleader6", 0x000, 0 }, + [358] = { 152, "gsleader7", 0x000, 0 }, + [359] = { 153, "gsleader8", 0x000, 0 }, + [360] = { 154, "gsbigfour1", 0x000, 0 }, + [361] = { 155, "gsbigfour2", 0x000, 0 }, + [362] = { 156, "gsbigfour3", 0x000, 0 }, + [363] = { 157, "gsbigfour4", 0x000, 0 }, + [364] = { 158, "wataru", 0x000, 0 }, + [365] = { 159, "gsmama", 0x000, 0 }, + [366] = { 160, "ookido", 0x000, 0 }, + [367] = { 161, "gsdsboy", 0x000, 0 }, + [368] = { 162, "gsleader9", 0x000, 0 }, + [369] = { 163, "gsleader10", 0x000, 0 }, + [370] = { 164, "gsleader11", 0x000, 0 }, + [371] = { 165, "gsleader12", 0x000, 0 }, + [372] = { 166, "gsleader13", 0x000, 0 }, + [373] = { 167, "gsleader14", 0x000, 0 }, + [374] = { 168, "gsleader15", 0x000, 0 }, + [375] = { 169, "gsleader16", 0x000, 0 }, + [377] = { 184, "mania", 0x000, 0 }, + [378] = { 185, "juggrer", 0x000, 0 }, + [381] = { 186, "usokky", 0x000, 0 }, + [382] = { 187, "minaki", 0x000, 0 }, + [383] = { 188, "thlonowner", 0x000, 0 }, + [384] = { 189, "instructor", 0x000, 0 }, + [389] = { 190, "kurumi", 0x000, 0 }, + [392] = { 191, "nanami", 0x000, 0 }, + [393] = { 192, "redmama", 0x000, 0 }, + [394] = { 193, "counterm", 0x000, 0 }, + [395] = { 194, "kabigon", 0x006, 23 }, + [406] = { 195, "ice", 0x006, 0 }, + [409] = { 200, "rotomf", 0x000, 0 }, + [410] = { 198, "rotoms", 0x000, 0 }, + [411] = { 197, "rotomi", 0x000, 0 }, + [412] = { 199, "rotomw", 0x000, 0 }, + [413] = { 196, "rotomg", 0x000, 0 }, + [349] = { 243, "stop", 0x000, 23 }, + [234] = { 242, "mono_pip", 0x024, 0 }, + [350] = { 229, "gtorophy", 0x024, 0 }, + [376] = { 230, "storophy", 0x024, 0 }, + [379] = { 231, "btorophy", 0x024, 0 }, + [210] = { 239, "pokewall", 0x024, 23 }, + [396] = { 241, "medal", 0x024, 23 }, + [397] = { 240, "scroll", 0x024, 0 }, + [398] = { 237, "signshoes", 0x024, 0 }, + [399] = { 234, "signclothes", 0x024, 0 }, + [400] = { 235, "signflag", 0x024, 23 }, + [401] = { 236, "signpokegear", 0x024, 0 }, + [402] = { 238, "signball", 0x024, 0 }, + [403] = { 232, "statuehero", 0x024, 0 }, + [404] = { 233, "statueheroine", 0x024, 0 }, + [428] = { 297, "follower_mon_bulbasaur", 0x227, 19 }, + [429] = { 298, "follower_mon_ivysaur", 0x227, 19 }, + [430] = { 299, "follower_mon_venusaur", 0x227, 19 }, + [432] = { 301, "follower_mon_charmander", 0x227, 19 }, + [433] = { 302, "follower_mon_charmeleon", 0x227, 19 }, + [434] = { 303, "follower_mon_charizard", 0x227, 19 }, + [435] = { 304, "follower_mon_squirtle", 0x227, 19 }, + [436] = { 305, "follower_mon_wartortle", 0x227, 19 }, + [437] = { 306, "follower_mon_blastoise", 0x227, 19 }, + [438] = { 307, "follower_mon_caterpie", 0x227, 19 }, + [439] = { 308, "follower_mon_metapod", 0x227, 19 }, + [440] = { 309, "follower_mon_butterfree", 0x227, 19 }, + [441] = { 310, "follower_mon_weedle", 0x227, 19 }, + [442] = { 311, "follower_mon_kakuna", 0x227, 19 }, + [443] = { 312, "follower_mon_beedrill", 0x227, 19 }, + [444] = { 313, "follower_mon_pidgey", 0x227, 19 }, + [445] = { 314, "follower_mon_pidgeotto", 0x227, 19 }, + [446] = { 315, "follower_mon_pidgeot", 0x227, 19 }, + [447] = { 316, "follower_mon_rattata", 0x227, 19 }, + [448] = { 317, "follower_mon_raticate", 0x227, 19 }, + [449] = { 318, "follower_mon_spearow", 0x227, 19 }, + [450] = { 319, "follower_mon_fearow", 0x227, 19 }, + [451] = { 320, "follower_mon_ekans", 0x227, 19 }, + [452] = { 321, "follower_mon_arbok", 0x227, 19 }, + [453] = { 322, "follower_mon_pikachu", 0x227, 19 }, + [455] = { 324, "follower_mon_raichu", 0x227, 19 }, + [456] = { 325, "follower_mon_sandshrew", 0x227, 19 }, + [457] = { 326, "follower_mon_sandslash", 0x227, 19 }, + [458] = { 327, "follower_mon_nidoran_f", 0x227, 19 }, + [459] = { 328, "follower_mon_nidorina", 0x227, 19 }, + [460] = { 329, "follower_mon_nidoqueen", 0x227, 19 }, + [461] = { 330, "follower_mon_nidoran_m", 0x227, 19 }, + [462] = { 331, "follower_mon_nidorino", 0x227, 19 }, + [463] = { 332, "follower_mon_nidoking", 0x227, 19 }, + [464] = { 333, "follower_mon_clefairy", 0x227, 19 }, + [465] = { 334, "follower_mon_clefable", 0x227, 19 }, + [466] = { 335, "follower_mon_vulpix", 0x227, 19 }, + [467] = { 336, "follower_mon_ninetales", 0x227, 19 }, + [468] = { 337, "follower_mon_jigglypuff", 0x227, 19 }, + [469] = { 338, "follower_mon_wigglytuff", 0x227, 19 }, + [470] = { 339, "follower_mon_zubat", 0x227, 19 }, + [471] = { 340, "follower_mon_golbat", 0x227, 19 }, + [472] = { 341, "follower_mon_oddish", 0x227, 19 }, + [473] = { 342, "follower_mon_gloom", 0x227, 19 }, + [474] = { 343, "follower_mon_vileplume", 0x227, 19 }, + [475] = { 344, "follower_mon_paras", 0x227, 19 }, + [476] = { 345, "follower_mon_parasect", 0x227, 19 }, + [477] = { 346, "follower_mon_venonat", 0x227, 19 }, + [478] = { 347, "follower_mon_venomoth", 0x227, 19 }, + [479] = { 348, "follower_mon_diglett", 0x226, 19 }, + [480] = { 349, "follower_mon_dugtrio", 0x226, 19 }, + [481] = { 350, "follower_mon_meowth", 0x227, 19 }, + [482] = { 351, "follower_mon_persian", 0x227, 19 }, + [483] = { 352, "follower_mon_psyduck", 0x227, 19 }, + [484] = { 353, "follower_mon_golduck", 0x227, 19 }, + [485] = { 354, "follower_mon_mankey", 0x227, 19 }, + [486] = { 355, "follower_mon_primeape", 0x227, 19 }, + [487] = { 356, "follower_mon_growlithe", 0x227, 19 }, + [488] = { 357, "follower_mon_arcanine", 0x227, 19 }, + [489] = { 358, "follower_mon_poliwag", 0x227, 19 }, + [490] = { 359, "follower_mon_poliwhirl", 0x227, 19 }, + [491] = { 360, "follower_mon_poliwrath", 0x227, 19 }, + [492] = { 361, "follower_mon_abra", 0x227, 19 }, + [493] = { 362, "follower_mon_kadabra", 0x227, 19 }, + [494] = { 363, "follower_mon_alakazam", 0x227, 19 }, + [495] = { 364, "follower_mon_machop", 0x227, 19 }, + [496] = { 365, "follower_mon_machoke", 0x227, 19 }, + [497] = { 366, "follower_mon_machamp", 0x227, 19 }, + [498] = { 367, "follower_mon_bellsprout", 0x227, 19 }, + [499] = { 368, "follower_mon_weepinbell", 0x227, 19 }, + [500] = { 369, "follower_mon_victreebel", 0x227, 19 }, + [501] = { 370, "follower_mon_tentacool", 0x227, 19 }, + [502] = { 371, "follower_mon_tentacruel", 0x227, 19 }, + [503] = { 372, "follower_mon_geodude", 0x227, 19 }, + [504] = { 373, "follower_mon_graveler", 0x227, 19 }, + [505] = { 374, "follower_mon_golem", 0x227, 19 }, + [506] = { 375, "follower_mon_ponyta", 0x227, 19 }, + [507] = { 376, "follower_mon_rapidash", 0x227, 19 }, + [508] = { 377, "follower_mon_slowpoke", 0x227, 19 }, + [509] = { 378, "follower_mon_slowbro", 0x227, 19 }, + [510] = { 379, "follower_mon_magnemite", 0x227, 19 }, + [511] = { 380, "follower_mon_magneton", 0x227, 19 }, + [512] = { 381, "follower_mon_farfetchd", 0x227, 19 }, + [513] = { 382, "follower_mon_doduo", 0x227, 19 }, + [514] = { 383, "follower_mon_dodrio", 0x227, 19 }, + [515] = { 384, "follower_mon_seel", 0x227, 19 }, + [516] = { 385, "follower_mon_dewgong", 0x227, 19 }, + [517] = { 386, "follower_mon_grimer", 0x227, 19 }, + [518] = { 387, "follower_mon_muk", 0x227, 19 }, + [519] = { 388, "follower_mon_shellder", 0x227, 19 }, + [520] = { 389, "follower_mon_cloyster", 0x227, 19 }, + [521] = { 390, "follower_mon_gastly", 0x227, 19 }, + [522] = { 391, "follower_mon_haunter", 0x227, 19 }, + [523] = { 392, "follower_mon_gengar", 0x227, 19 }, + [524] = { 393, "follower_mon_onix", 0x227, 19 }, + [525] = { 394, "follower_mon_drowzee", 0x227, 19 }, + [526] = { 395, "follower_mon_hypno", 0x227, 19 }, + [527] = { 396, "follower_mon_krabby", 0x227, 19 }, + [528] = { 397, "follower_mon_kingler", 0x227, 19 }, + [529] = { 398, "follower_mon_voltorb", 0x227, 19 }, + [530] = { 399, "follower_mon_electrode", 0x227, 19 }, + [531] = { 400, "follower_mon_exeggcute", 0x227, 19 }, + [532] = { 401, "follower_mon_exeggutor", 0x227, 19 }, + [533] = { 402, "follower_mon_cubone", 0x227, 19 }, + [534] = { 403, "follower_mon_marowak", 0x227, 19 }, + [535] = { 404, "follower_mon_hitmonlee", 0x227, 19 }, + [536] = { 405, "follower_mon_hitmonchan", 0x227, 19 }, + [537] = { 406, "follower_mon_lickitung", 0x227, 19 }, + [538] = { 407, "follower_mon_koffing", 0x227, 19 }, + [539] = { 408, "follower_mon_weezing", 0x227, 19 }, + [540] = { 409, "follower_mon_rhyhorn", 0x227, 19 }, + [541] = { 410, "follower_mon_rhydon", 0x227, 19 }, + [542] = { 411, "follower_mon_chansey", 0x227, 19 }, + [543] = { 412, "follower_mon_tangela", 0x227, 19 }, + [544] = { 413, "follower_mon_kangaskhan", 0x227, 19 }, + [545] = { 414, "follower_mon_horsea", 0x227, 19 }, + [546] = { 415, "follower_mon_seadra", 0x227, 19 }, + [547] = { 416, "follower_mon_goldeen", 0x227, 19 }, + [548] = { 417, "follower_mon_seaking", 0x227, 19 }, + [549] = { 418, "follower_mon_staryu", 0x227, 19 }, + [550] = { 419, "follower_mon_starmie", 0x227, 19 }, + [551] = { 420, "follower_mon_mr_mime", 0x227, 19 }, + [552] = { 421, "follower_mon_scyther", 0x227, 19 }, + [553] = { 422, "follower_mon_jynx", 0x227, 19 }, + [554] = { 423, "follower_mon_electabuzz", 0x227, 19 }, + [555] = { 424, "follower_mon_magmar", 0x227, 19 }, + [556] = { 425, "follower_mon_pinsir", 0x227, 19 }, + [557] = { 426, "follower_mon_tauros", 0x227, 19 }, + [558] = { 427, "follower_mon_magikarp", 0x227, 19 }, + [559] = { 428, "follower_mon_gyarados", 0x227, 19 }, + [560] = { 429, "follower_mon_lapras", 0x227, 19 }, + [561] = { 430, "follower_mon_ditto", 0x227, 19 }, + [562] = { 431, "follower_mon_eevee", 0x227, 19 }, + [563] = { 432, "follower_mon_vaporeon", 0x227, 19 }, + [564] = { 433, "follower_mon_jolteon", 0x227, 19 }, + [565] = { 434, "follower_mon_flareon", 0x227, 19 }, + [566] = { 435, "follower_mon_porygon", 0x227, 19 }, + [567] = { 436, "follower_mon_omanyte", 0x227, 19 }, + [568] = { 437, "follower_mon_omastar", 0x227, 19 }, + [569] = { 438, "follower_mon_kabuto", 0x227, 19 }, + [570] = { 439, "follower_mon_kabutops", 0x227, 19 }, + [571] = { 440, "follower_mon_aerodactyl", 0x227, 19 }, + [572] = { 441, "follower_mon_snorlax", 0x227, 19 }, + [573] = { 442, "follower_mon_articuno", 0x227, 19 }, + [574] = { 443, "follower_mon_zapdos", 0x227, 19 }, + [575] = { 444, "follower_mon_moltres", 0x227, 19 }, + [576] = { 445, "follower_mon_dratini", 0x227, 19 }, + [577] = { 446, "follower_mon_dragonair", 0x227, 19 }, + [578] = { 447, "follower_mon_dragonite", 0x227, 19 }, + [579] = { 448, "follower_mon_mewtwo", 0x227, 19 }, + [580] = { 449, "follower_mon_mew", 0x227, 19 }, + [581] = { 450, "follower_mon_chikorita", 0x227, 19 }, + [582] = { 451, "follower_mon_bayleef", 0x227, 19 }, + [583] = { 452, "follower_mon_meganium", 0x227, 19 }, + [585] = { 454, "follower_mon_cyndaquil", 0x227, 19 }, + [586] = { 455, "follower_mon_quilava", 0x227, 19 }, + [587] = { 456, "follower_mon_typhlosion", 0x227, 19 }, + [588] = { 457, "follower_mon_totodile", 0x227, 19 }, + [589] = { 458, "follower_mon_croconaw", 0x227, 19 }, + [590] = { 459, "follower_mon_feraligatr", 0x227, 19 }, + [591] = { 460, "follower_mon_sentret", 0x227, 19 }, + [592] = { 461, "follower_mon_furret", 0x227, 19 }, + [593] = { 462, "follower_mon_hoothoot", 0x227, 19 }, + [594] = { 463, "follower_mon_noctowl", 0x227, 19 }, + [595] = { 464, "follower_mon_ledyba", 0x227, 19 }, + [596] = { 465, "follower_mon_ledian", 0x227, 19 }, + [597] = { 466, "follower_mon_spinarak", 0x227, 19 }, + [598] = { 467, "follower_mon_ariados", 0x227, 19 }, + [599] = { 468, "follower_mon_crobat", 0x227, 19 }, + [600] = { 469, "follower_mon_chinchou", 0x227, 19 }, + [601] = { 470, "follower_mon_lanturn", 0x227, 19 }, + [602] = { 471, "follower_mon_pichu", 0x227, 19 }, + [604] = { 473, "follower_mon_cleffa", 0x227, 19 }, + [605] = { 474, "follower_mon_igglybuff", 0x227, 19 }, + [606] = { 475, "follower_mon_togepi", 0x227, 19 }, + [607] = { 476, "follower_mon_togetic", 0x227, 19 }, + [608] = { 477, "follower_mon_natu", 0x227, 19 }, + [609] = { 478, "follower_mon_xatu", 0x227, 19 }, + [610] = { 479, "follower_mon_mareep", 0x227, 19 }, + [611] = { 480, "follower_mon_flaaffy", 0x227, 19 }, + [612] = { 481, "follower_mon_ampharos", 0x227, 19 }, + [613] = { 482, "follower_mon_bellossom", 0x227, 19 }, + [614] = { 483, "follower_mon_marill", 0x227, 19 }, + [615] = { 484, "follower_mon_azumarill", 0x227, 19 }, + [616] = { 485, "follower_mon_sudowoodo", 0x227, 19 }, + [617] = { 486, "follower_mon_politoed", 0x227, 19 }, + [618] = { 487, "follower_mon_hoppip", 0x227, 19 }, + [619] = { 488, "follower_mon_skiploom", 0x227, 19 }, + [620] = { 489, "follower_mon_jumpluff", 0x227, 19 }, + [621] = { 490, "follower_mon_aipom", 0x227, 19 }, + [622] = { 491, "follower_mon_sunkern", 0x227, 19 }, + [623] = { 492, "follower_mon_sunflora", 0x227, 19 }, + [624] = { 493, "follower_mon_yanma", 0x227, 19 }, + [625] = { 494, "follower_mon_wooper", 0x227, 19 }, + [626] = { 495, "follower_mon_quagsire", 0x227, 19 }, + [627] = { 496, "follower_mon_espeon", 0x227, 19 }, + [628] = { 497, "follower_mon_umbreon", 0x227, 19 }, + [629] = { 498, "follower_mon_murkrow", 0x227, 19 }, + [630] = { 499, "follower_mon_slowking", 0x227, 19 }, + [631] = { 500, "follower_mon_misdreavus", 0x227, 19 }, + [632] = { 501, "follower_mon_unown_a", 0x227, 19 }, + [633] = { 502, "follower_mon_unown_b", 0x227, 19 }, + [634] = { 503, "follower_mon_unown_c", 0x227, 19 }, + [635] = { 504, "follower_mon_unown_d", 0x227, 19 }, + [636] = { 505, "follower_mon_unown_e", 0x227, 19 }, + [637] = { 506, "follower_mon_unown_f", 0x227, 19 }, + [638] = { 507, "follower_mon_unown_g", 0x227, 19 }, + [639] = { 508, "follower_mon_unown_h", 0x227, 19 }, + [640] = { 509, "follower_mon_unown_i", 0x227, 19 }, + [641] = { 510, "follower_mon_unown_j", 0x227, 19 }, + [642] = { 511, "follower_mon_unown_k", 0x227, 19 }, + [643] = { 512, "follower_mon_unown_l", 0x227, 19 }, + [644] = { 513, "follower_mon_unown_m", 0x227, 19 }, + [645] = { 514, "follower_mon_unown_n", 0x227, 19 }, + [646] = { 515, "follower_mon_unown_o", 0x227, 19 }, + [647] = { 516, "follower_mon_unown_p", 0x227, 19 }, + [648] = { 517, "follower_mon_unown_q", 0x227, 19 }, + [649] = { 518, "follower_mon_unown_r", 0x227, 19 }, + [650] = { 519, "follower_mon_unown_s", 0x227, 19 }, + [651] = { 520, "follower_mon_unown_t", 0x227, 19 }, + [652] = { 521, "follower_mon_unown_u", 0x227, 19 }, + [653] = { 522, "follower_mon_unown_v", 0x227, 19 }, + [654] = { 523, "follower_mon_unown_w", 0x227, 19 }, + [655] = { 524, "follower_mon_unown_x", 0x227, 19 }, + [656] = { 525, "follower_mon_unown_y", 0x227, 19 }, + [657] = { 526, "follower_mon_unown_z", 0x227, 19 }, + [658] = { 527, "follower_mon_unown_qmark", 0x227, 19 }, + [659] = { 528, "follower_mon_unown_excl", 0x227, 19 }, + [660] = { 529, "follower_mon_wobbuffet", 0x227, 19 }, + [662] = { 531, "follower_mon_girafarig", 0x227, 19 }, + [663] = { 532, "follower_mon_pineco", 0x227, 19 }, + [664] = { 533, "follower_mon_forretress", 0x227, 19 }, + [665] = { 534, "follower_mon_dunsparce", 0x227, 19 }, + [666] = { 535, "follower_mon_gligar", 0x227, 19 }, + [667] = { 536, "follower_mon_steelix", 0x208, 20 }, + [669] = { 538, "follower_mon_snubbull", 0x227, 19 }, + [670] = { 539, "follower_mon_granbull", 0x227, 19 }, + [671] = { 540, "follower_mon_qwilfish", 0x227, 19 }, + [672] = { 541, "follower_mon_scizor", 0x227, 19 }, + [673] = { 542, "follower_mon_shuckle", 0x227, 19 }, + [674] = { 543, "follower_mon_heracross", 0x227, 19 }, + [676] = { 545, "follower_mon_sneasel", 0x227, 19 }, + [677] = { 546, "follower_mon_teddiursa", 0x227, 19 }, + [678] = { 547, "follower_mon_ursaring", 0x227, 19 }, + [679] = { 548, "follower_mon_slugma", 0x227, 19 }, + [680] = { 549, "follower_mon_magcargo", 0x227, 19 }, + [681] = { 550, "follower_mon_swinub", 0x227, 19 }, + [682] = { 551, "follower_mon_piloswine", 0x227, 19 }, + [683] = { 552, "follower_mon_corsola", 0x227, 19 }, + [684] = { 553, "follower_mon_remoraid", 0x227, 19 }, + [685] = { 554, "follower_mon_octillery", 0x227, 19 }, + [686] = { 555, "follower_mon_delibird", 0x227, 19 }, + [687] = { 556, "follower_mon_mantine", 0x227, 19 }, + [688] = { 557, "follower_mon_skarmory", 0x227, 19 }, + [689] = { 558, "follower_mon_houndour", 0x227, 19 }, + [690] = { 559, "follower_mon_houndoom", 0x227, 19 }, + [691] = { 560, "follower_mon_kingdra", 0x227, 19 }, + [692] = { 561, "follower_mon_phanpy", 0x227, 19 }, + [693] = { 562, "follower_mon_donphan", 0x227, 19 }, + [694] = { 563, "follower_mon_porygon2", 0x227, 19 }, + [695] = { 564, "follower_mon_stantler", 0x227, 19 }, + [696] = { 565, "follower_mon_smeargle", 0x227, 19 }, + [697] = { 566, "follower_mon_tyrogue", 0x227, 19 }, + [698] = { 567, "follower_mon_hitmontop", 0x227, 19 }, + [699] = { 568, "follower_mon_smoochum", 0x227, 19 }, + [700] = { 569, "follower_mon_elekid", 0x227, 19 }, + [701] = { 570, "follower_mon_magby", 0x227, 19 }, + [702] = { 571, "follower_mon_miltank", 0x227, 19 }, + [703] = { 572, "follower_mon_blissey", 0x227, 19 }, + [704] = { 573, "follower_mon_raikou", 0x227, 19 }, + [705] = { 574, "follower_mon_entei", 0x227, 19 }, + [706] = { 575, "follower_mon_suicune", 0x227, 19 }, + [707] = { 576, "follower_mon_larvitar", 0x227, 19 }, + [708] = { 577, "follower_mon_pupitar", 0x227, 19 }, + [709] = { 578, "follower_mon_tyranitar", 0x227, 19 }, + [710] = { 579, "follower_mon_lugia", 0x208, 20 }, + [711] = { 580, "follower_mon_ho_oh", 0x208, 20 }, + [712] = { 581, "follower_mon_celebi", 0x227, 19 }, + [713] = { 582, "follower_mon_treecko", 0x227, 19 }, + [714] = { 583, "follower_mon_grovyle", 0x227, 19 }, + [715] = { 584, "follower_mon_sceptile", 0x227, 19 }, + [716] = { 585, "follower_mon_torchic", 0x227, 19 }, + [717] = { 586, "follower_mon_combusken", 0x227, 19 }, + [718] = { 587, "follower_mon_blaziken", 0x227, 19 }, + [719] = { 588, "follower_mon_mudkip", 0x227, 19 }, + [720] = { 589, "follower_mon_marshtomp", 0x227, 19 }, + [721] = { 590, "follower_mon_swampert", 0x227, 19 }, + [722] = { 591, "follower_mon_poochyena", 0x227, 19 }, + [723] = { 592, "follower_mon_mightyena", 0x227, 19 }, + [724] = { 593, "follower_mon_zigzagoon", 0x227, 19 }, + [725] = { 594, "follower_mon_linoone", 0x227, 19 }, + [726] = { 595, "follower_mon_wurmple", 0x227, 19 }, + [727] = { 596, "follower_mon_silcoon", 0x227, 19 }, + [728] = { 597, "follower_mon_beautifly", 0x227, 19 }, + [729] = { 598, "follower_mon_cascoon", 0x227, 19 }, + [730] = { 599, "follower_mon_dustox", 0x227, 19 }, + [731] = { 600, "follower_mon_lotad", 0x227, 19 }, + [732] = { 601, "follower_mon_lombre", 0x227, 19 }, + [733] = { 602, "follower_mon_ludicolo", 0x227, 19 }, + [734] = { 603, "follower_mon_seedot", 0x227, 19 }, + [735] = { 604, "follower_mon_nuzleaf", 0x227, 19 }, + [736] = { 605, "follower_mon_shiftry", 0x227, 19 }, + [737] = { 606, "follower_mon_taillow", 0x227, 19 }, + [738] = { 607, "follower_mon_swellow", 0x227, 19 }, + [739] = { 608, "follower_mon_wingull", 0x227, 19 }, + [740] = { 609, "follower_mon_pelipper", 0x227, 19 }, + [741] = { 610, "follower_mon_ralts", 0x227, 19 }, + [742] = { 611, "follower_mon_kirlia", 0x227, 19 }, + [743] = { 612, "follower_mon_gardevoir", 0x227, 19 }, + [744] = { 613, "follower_mon_surskit", 0x227, 19 }, + [745] = { 614, "follower_mon_masquerain", 0x227, 19 }, + [746] = { 615, "follower_mon_shroomish", 0x227, 19 }, + [747] = { 616, "follower_mon_breloom", 0x227, 19 }, + [748] = { 617, "follower_mon_slakoth", 0x227, 19 }, + [749] = { 618, "follower_mon_vigoroth", 0x227, 19 }, + [750] = { 619, "follower_mon_slaking", 0x227, 19 }, + [751] = { 620, "follower_mon_nincada", 0x227, 19 }, + [752] = { 621, "follower_mon_ninjask", 0x227, 19 }, + [753] = { 622, "follower_mon_shedinja", 0x227, 19 }, + [754] = { 623, "follower_mon_whismur", 0x227, 19 }, + [755] = { 624, "follower_mon_loudred", 0x227, 19 }, + [756] = { 625, "follower_mon_exploud", 0x227, 19 }, + [757] = { 626, "follower_mon_makuhita", 0x227, 19 }, + [758] = { 627, "follower_mon_hariyama", 0x227, 19 }, + [759] = { 628, "follower_mon_azurill", 0x227, 19 }, + [760] = { 629, "follower_mon_nosepass", 0x227, 19 }, + [761] = { 630, "follower_mon_skitty", 0x227, 19 }, + [762] = { 631, "follower_mon_delcatty", 0x227, 19 }, + [763] = { 632, "follower_mon_sableye", 0x227, 19 }, + [764] = { 633, "follower_mon_mawile", 0x227, 19 }, + [765] = { 634, "follower_mon_aron", 0x227, 19 }, + [766] = { 635, "follower_mon_lairon", 0x227, 19 }, + [767] = { 636, "follower_mon_aggron", 0x227, 19 }, + [768] = { 637, "follower_mon_meditite", 0x227, 19 }, + [769] = { 638, "follower_mon_medicham", 0x227, 19 }, + [770] = { 639, "follower_mon_electrike", 0x227, 19 }, + [771] = { 640, "follower_mon_manectric", 0x227, 19 }, + [772] = { 641, "follower_mon_plusle", 0x227, 19 }, + [773] = { 642, "follower_mon_minun", 0x227, 19 }, + [774] = { 643, "follower_mon_volbeat", 0x227, 19 }, + [775] = { 644, "follower_mon_illumise", 0x227, 19 }, + [776] = { 645, "follower_mon_roselia", 0x227, 19 }, + [777] = { 646, "follower_mon_gulpin", 0x227, 19 }, + [778] = { 647, "follower_mon_swalot", 0x227, 19 }, + [779] = { 648, "follower_mon_carvanha", 0x227, 19 }, + [780] = { 649, "follower_mon_sharpedo", 0x227, 19 }, + [781] = { 650, "follower_mon_wailmer", 0x227, 19 }, + [782] = { 651, "follower_mon_wailord", 0x208, 20 }, + [783] = { 652, "follower_mon_numel", 0x227, 19 }, + [784] = { 653, "follower_mon_camerupt", 0x227, 19 }, + [785] = { 654, "follower_mon_torkoal", 0x227, 19 }, + [786] = { 655, "follower_mon_spoink", 0x227, 19 }, + [787] = { 656, "follower_mon_grumpig", 0x227, 19 }, + [788] = { 657, "follower_mon_spinda", 0x227, 19 }, + [789] = { 658, "follower_mon_trapinch", 0x227, 19 }, + [790] = { 659, "follower_mon_vibrava", 0x227, 19 }, + [791] = { 660, "follower_mon_flygon", 0x227, 19 }, + [792] = { 661, "follower_mon_cacnea", 0x227, 19 }, + [793] = { 662, "follower_mon_cacturne", 0x227, 19 }, + [794] = { 663, "follower_mon_swablu", 0x227, 19 }, + [795] = { 664, "follower_mon_altaria", 0x227, 19 }, + [796] = { 665, "follower_mon_zangoose", 0x227, 19 }, + [797] = { 666, "follower_mon_seviper", 0x227, 19 }, + [798] = { 667, "follower_mon_lunatone", 0x227, 19 }, + [799] = { 668, "follower_mon_solrock", 0x227, 19 }, + [800] = { 669, "follower_mon_barboach", 0x227, 19 }, + [801] = { 670, "follower_mon_whiscash", 0x227, 19 }, + [802] = { 671, "follower_mon_corphish", 0x227, 19 }, + [803] = { 672, "follower_mon_crawdaunt", 0x227, 19 }, + [804] = { 673, "follower_mon_baltoy", 0x227, 19 }, + [805] = { 674, "follower_mon_claydol", 0x227, 19 }, + [806] = { 675, "follower_mon_lileep", 0x227, 19 }, + [807] = { 676, "follower_mon_cradily", 0x227, 19 }, + [808] = { 677, "follower_mon_anorith", 0x227, 19 }, + [809] = { 678, "follower_mon_armaldo", 0x227, 19 }, + [810] = { 679, "follower_mon_feebas", 0x227, 19 }, + [811] = { 680, "follower_mon_milotic", 0x227, 19 }, + [812] = { 681, "follower_mon_castform", 0x227, 19 }, + [813] = { 682, "follower_mon_kecleon", 0x227, 19 }, + [814] = { 683, "follower_mon_shuppet", 0x227, 19 }, + [815] = { 684, "follower_mon_banette", 0x227, 19 }, + [816] = { 685, "follower_mon_duskull", 0x227, 19 }, + [817] = { 686, "follower_mon_dusclops", 0x227, 19 }, + [818] = { 687, "follower_mon_tropius", 0x227, 19 }, + [819] = { 688, "follower_mon_chimecho", 0x227, 19 }, + [820] = { 689, "follower_mon_absol", 0x227, 19 }, + [821] = { 690, "follower_mon_wynaut", 0x227, 19 }, + [822] = { 691, "follower_mon_snorunt", 0x227, 19 }, + [823] = { 692, "follower_mon_glalie", 0x227, 19 }, + [824] = { 693, "follower_mon_spheal", 0x227, 19 }, + [825] = { 694, "follower_mon_sealeo", 0x227, 19 }, + [826] = { 695, "follower_mon_walrein", 0x227, 19 }, + [827] = { 696, "follower_mon_clamperl", 0x227, 19 }, + [828] = { 697, "follower_mon_huntail", 0x227, 19 }, + [829] = { 698, "follower_mon_gorebyss", 0x227, 19 }, + [830] = { 699, "follower_mon_relicanth", 0x227, 19 }, + [831] = { 700, "follower_mon_luvdisc", 0x227, 19 }, + [832] = { 701, "follower_mon_bagon", 0x227, 19 }, + [833] = { 702, "follower_mon_shelgon", 0x227, 19 }, + [834] = { 703, "follower_mon_salamence", 0x227, 19 }, + [835] = { 704, "follower_mon_beldum", 0x227, 19 }, + [836] = { 705, "follower_mon_metang", 0x227, 19 }, + [837] = { 706, "follower_mon_metagross", 0x227, 19 }, + [838] = { 707, "follower_mon_regirock", 0x227, 19 }, + [839] = { 708, "follower_mon_regice", 0x227, 19 }, + [840] = { 709, "follower_mon_registeel", 0x227, 19 }, + [841] = { 710, "follower_mon_latias", 0x227, 19 }, + [842] = { 711, "follower_mon_latios", 0x227, 19 }, + [843] = { 712, "follower_mon_kyogre", 0x208, 20 }, + [844] = { 713, "follower_mon_groudon", 0x208, 20 }, + [845] = { 714, "follower_mon_rayquaza", 0x208, 20 }, + [846] = { 715, "follower_mon_jirachi", 0x227, 19 }, + [847] = { 716, "follower_mon_deoxys", 0x227, 19 }, + [848] = { 717, "follower_mon_deoxys_atk", 0x227, 19 }, + [849] = { 718, "follower_mon_deoxys_def", 0x227, 19 }, + [850] = { 719, "follower_mon_deoxys_spd", 0x227, 19 }, + [851] = { 720, "follower_mon_turtwig", 0x227, 19 }, + [852] = { 721, "follower_mon_grotle", 0x227, 19 }, + [853] = { 722, "follower_mon_torterra", 0x227, 19 }, + [854] = { 723, "follower_mon_chimchar", 0x227, 19 }, + [855] = { 724, "follower_mon_monferno", 0x227, 19 }, + [856] = { 725, "follower_mon_infernape", 0x227, 19 }, + [857] = { 726, "follower_mon_piplup", 0x227, 19 }, + [858] = { 727, "follower_mon_prinplup", 0x227, 19 }, + [859] = { 728, "follower_mon_empoleon", 0x227, 19 }, + [860] = { 729, "follower_mon_starly", 0x227, 19 }, + [861] = { 730, "follower_mon_staravia", 0x227, 19 }, + [862] = { 731, "follower_mon_staraptor", 0x227, 19 }, + [863] = { 732, "follower_mon_bidoof", 0x227, 19 }, + [864] = { 733, "follower_mon_bibarel", 0x227, 19 }, + [865] = { 734, "follower_mon_kricketot", 0x227, 19 }, + [866] = { 735, "follower_mon_kricketune", 0x227, 19 }, + [867] = { 736, "follower_mon_shinx", 0x227, 19 }, + [868] = { 737, "follower_mon_luxio", 0x227, 19 }, + [869] = { 738, "follower_mon_luxray", 0x227, 19 }, + [870] = { 739, "follower_mon_budew", 0x227, 19 }, + [871] = { 740, "follower_mon_roserade", 0x227, 19 }, + [872] = { 741, "follower_mon_cranidos", 0x227, 19 }, + [873] = { 742, "follower_mon_rampardos", 0x227, 19 }, + [874] = { 743, "follower_mon_shieldon", 0x227, 19 }, + [875] = { 744, "follower_mon_bastiodon", 0x227, 19 }, + [876] = { 745, "follower_mon_burmy", 0x227, 19 }, + [877] = { 746, "follower_mon_burmy_sandy", 0x227, 19 }, + [878] = { 747, "follower_mon_burmy_trash", 0x227, 19 }, + [879] = { 748, "follower_mon_wormadam", 0x227, 19 }, + [880] = { 749, "follower_mon_wormadam_sandy", 0x227, 19 }, + [881] = { 750, "follower_mon_wormadam_trash", 0x227, 19 }, + [882] = { 751, "follower_mon_mothim", 0x227, 19 }, + [883] = { 752, "follower_mon_combee", 0x227, 19 }, + [885] = { 754, "follower_mon_vespiquen", 0x227, 19 }, + [886] = { 755, "follower_mon_pachirisu", 0x227, 19 }, + [887] = { 756, "follower_mon_buizel", 0x227, 19 }, + [888] = { 757, "follower_mon_floatzel", 0x227, 19 }, + [889] = { 758, "follower_mon_cherubi", 0x227, 19 }, + [890] = { 759, "follower_mon_cherrim", 0x227, 19 }, + [891] = { 760, "follower_mon_shellos_west", 0x227, 19 }, + [892] = { 761, "follower_mon_shellos_east", 0x227, 19 }, + [893] = { 762, "follower_mon_gastrodon_west", 0x227, 19 }, + [894] = { 763, "follower_mon_gastrodon_east", 0x227, 19 }, + [895] = { 764, "follower_mon_ambipom", 0x227, 19 }, + [896] = { 765, "follower_mon_drifloon", 0x227, 19 }, + [897] = { 766, "follower_mon_drifblim", 0x227, 19 }, + [898] = { 767, "follower_mon_buneary", 0x227, 19 }, + [899] = { 768, "follower_mon_lopunny", 0x227, 19 }, + [900] = { 769, "follower_mon_mismagius", 0x227, 19 }, + [901] = { 770, "follower_mon_honchkrow", 0x227, 19 }, + [902] = { 771, "follower_mon_glameow", 0x227, 19 }, + [903] = { 772, "follower_mon_purugly", 0x227, 19 }, + [904] = { 773, "follower_mon_chingling", 0x227, 19 }, + [905] = { 774, "follower_mon_stunky", 0x227, 19 }, + [906] = { 775, "follower_mon_skuntank", 0x227, 19 }, + [907] = { 776, "follower_mon_bronzor", 0x227, 19 }, + [908] = { 777, "follower_mon_bronzong", 0x227, 19 }, + [909] = { 778, "follower_mon_bonsly", 0x227, 19 }, + [910] = { 779, "follower_mon_mime_jr", 0x227, 19 }, + [911] = { 780, "follower_mon_happiny", 0x227, 19 }, + [912] = { 781, "follower_mon_chatot", 0x227, 19 }, + [913] = { 782, "follower_mon_spiritomb", 0x227, 19 }, + [914] = { 783, "follower_mon_gible", 0x227, 19 }, + [916] = { 785, "follower_mon_gabite", 0x227, 19 }, + [918] = { 787, "follower_mon_garchomp", 0x227, 19 }, + [920] = { 789, "follower_mon_munchlax", 0x227, 19 }, + [921] = { 790, "follower_mon_riolu", 0x227, 19 }, + [922] = { 791, "follower_mon_lucario", 0x227, 19 }, + [923] = { 792, "follower_mon_hippopotas", 0x227, 19 }, + [924] = { 793, "follower_mon_hippopotas_f", 0x227, 19 }, + [925] = { 794, "follower_mon_hippowdon", 0x227, 19 }, + [926] = { 795, "follower_mon_hippowdon_f", 0x227, 19 }, + [927] = { 796, "follower_mon_skorupi", 0x227, 19 }, + [928] = { 797, "follower_mon_drapion", 0x227, 19 }, + [929] = { 798, "follower_mon_croagunk", 0x227, 19 }, + [930] = { 799, "follower_mon_toxicroak", 0x227, 19 }, + [931] = { 800, "follower_mon_carnivine", 0x227, 19 }, + [932] = { 801, "follower_mon_finneon", 0x227, 19 }, + [933] = { 802, "follower_mon_lumineon", 0x227, 19 }, + [934] = { 803, "follower_mon_mantyke", 0x227, 19 }, + [935] = { 804, "follower_mon_snover", 0x227, 19 }, + [936] = { 805, "follower_mon_abomasnow", 0x227, 19 }, + [937] = { 806, "follower_mon_weavile", 0x227, 19 }, + [938] = { 807, "follower_mon_magnezone", 0x227, 19 }, + [939] = { 808, "follower_mon_lickilicky", 0x227, 19 }, + [940] = { 809, "follower_mon_rhyperior", 0x227, 19 }, + [941] = { 810, "follower_mon_tangrowth", 0x227, 19 }, + [942] = { 811, "follower_mon_electivire", 0x227, 19 }, + [943] = { 812, "follower_mon_magmortar", 0x227, 19 }, + [944] = { 813, "follower_mon_togekiss", 0x227, 19 }, + [945] = { 814, "follower_mon_yanmega", 0x227, 19 }, + [946] = { 815, "follower_mon_leafeon", 0x227, 19 }, + [947] = { 816, "follower_mon_glaceon", 0x227, 19 }, + [948] = { 817, "follower_mon_gliscor", 0x227, 19 }, + [949] = { 818, "follower_mon_mamoswine", 0x227, 19 }, + [950] = { 819, "follower_mon_porygon_z", 0x227, 19 }, + [951] = { 820, "follower_mon_gallade", 0x227, 19 }, + [952] = { 821, "follower_mon_probopass", 0x227, 19 }, + [953] = { 822, "follower_mon_dusknoir", 0x227, 19 }, + [954] = { 823, "follower_mon_froslass", 0x227, 19 }, + [955] = { 824, "follower_mon_rotom", 0x227, 19 }, + [956] = { 825, "follower_mon_rotom_heat", 0x227, 19 }, + [957] = { 826, "follower_mon_rotom_wash", 0x227, 19 }, + [958] = { 827, "follower_mon_rotom_frost", 0x227, 19 }, + [959] = { 828, "follower_mon_rotom_fan", 0x227, 19 }, + [960] = { 829, "follower_mon_rotom_mow", 0x227, 19 }, + [961] = { 830, "follower_mon_uxie", 0x227, 19 }, + [962] = { 831, "follower_mon_mesprit", 0x227, 19 }, + [963] = { 832, "follower_mon_azelf", 0x227, 19 }, + [964] = { 833, "follower_mon_dialga", 0x208, 20 }, + [965] = { 834, "follower_mon_palkia", 0x208, 20 }, + [966] = { 835, "follower_mon_heatran", 0x227, 19 }, + [967] = { 836, "follower_mon_regigigas", 0x208, 20 }, + [968] = { 837, "follower_mon_giratina", 0x208, 20 }, + [969] = { 838, "follower_mon_giratina_origin", 0x208, 20 }, + [970] = { 839, "follower_mon_cresselia", 0x227, 19 }, + [971] = { 840, "follower_mon_phione", 0x227, 19 }, + [972] = { 841, "follower_mon_manaphy", 0x227, 19 }, + [973] = { 842, "follower_mon_darkrai", 0x227, 19 }, + [974] = { 843, "follower_mon_shaymin", 0x227, 19 }, + [975] = { 844, "follower_mon_shaymin_sky", 0x227, 19 }, + [976] = { 845, "follower_mon_arceus_normal", 0x208, 20 }, + [977] = { 846, "follower_mon_arceus_fighting", 0x208, 20 }, + [978] = { 847, "follower_mon_arceus_flying", 0x208, 20 }, + [979] = { 848, "follower_mon_arceus_poison", 0x208, 20 }, + [980] = { 849, "follower_mon_arceus_ground", 0x208, 20 }, + [981] = { 850, "follower_mon_arceus_rock", 0x208, 20 }, + [982] = { 851, "follower_mon_arceus_bug", 0x208, 20 }, + [983] = { 852, "follower_mon_arceus_ghost", 0x208, 20 }, + [984] = { 853, "follower_mon_arceus_steel", 0x208, 20 }, + [985] = { 854, "follower_mon_arceus_mystery", 0x208, 20 }, + [986] = { 855, "follower_mon_arceus_fire", 0x208, 20 }, + [987] = { 856, "follower_mon_arceus_water", 0x208, 20 }, + [988] = { 857, "follower_mon_arceus_grass", 0x208, 20 }, + [989] = { 858, "follower_mon_arceus_electric", 0x208, 20 }, + [990] = { 859, "follower_mon_arceus_psychic", 0x208, 20 }, + [991] = { 860, "follower_mon_arceus_ice", 0x208, 20 }, + [992] = { 861, "follower_mon_arceus_dragon", 0x208, 20 }, + [993] = { 862, "follower_mon_arceus_dark", 0x208, 20 }, + [603] = { 472, "follower_mon_pichu_spiky", 0x227, 19 }, + [431] = { 300, "follower_mon_venusaur_f", 0x227, 19 }, + [454] = { 323, "follower_mon_pikachu_f", 0x227, 19 }, + [584] = { 453, "follower_mon_meganium_f", 0x227, 19 }, + [661] = { 530, "follower_mon_wobbuffet_f", 0x227, 19 }, + [675] = { 544, "follower_mon_heracross_f", 0x227, 19 }, + [668] = { 537, "follower_mon_steelix_f", 0x208, 20 }, + [884] = { 753, "follower_mon_combee_f", 0x227, 19 }, + [915] = { 784, "follower_mon_gible_f", 0x227, 19 }, + [917] = { 786, "follower_mon_gabite_f", 0x227, 19 }, + [919] = { 788, "follower_mon_garchomp_f", 0x227, 19 }, + [0] = { 69, "hero", 0x060, 7 }, + [258] = { 95, "rhero", 0x060, 7 }, + [259] = { 96, "rheroine", 0x060, 7 }, + [260] = { 97, "rbanzaihero", 0x140, 13 }, + [261] = { 98, "rbanzaiine", 0x140, 13 }, + [407] = { 99, "pkthhero", 0x060, 7 }, + [408] = { 100, "pkthheroine", 0x060, 7 }, + [262] = { 251, "bonguri", 0x247, 21 }, + [263] = { 255, "bonguri_r", 0x247, 21 }, + [264] = { 258, "bonguri_y", 0x247, 21 }, + [265] = { 252, "bonguri_b", 0x247, 21 }, + [266] = { 254, "bonguri_g", 0x247, 21 }, + [267] = { 257, "bonguri_p", 0x247, 21 }, + [268] = { 256, "bonguri_w", 0x247, 21 }, + [269] = { 253, "bonguri_bk", 0x247, 21 }, + [270] = { 262, "bonmi_r", 0x026, 22 }, + [271] = { 265, "bonmi_y", 0x026, 22 }, + [272] = { 259, "bonmi_b", 0x026, 22 }, + [273] = { 261, "bonmi_g", 0x026, 22 }, + [274] = { 264, "bonmi_p", 0x026, 22 }, + [275] = { 263, "bonmi_w", 0x026, 22 }, + [276] = { 260, "bonmi_bk", 0x026, 22 }, + [277] = { 103, "brains1", 0x000, 0 }, + [278] = { 104, "brains2", 0x000, 0 }, + [279] = { 105, "brains3", 0x000, 0 }, + [280] = { 106, "brains4", 0x000, 0 }, + [281] = { 107, "princess", 0x000, 0 }, + [282] = { 108, "bfsm", 0x000, 0 }, + [283] = { 109, "bfsw1", 0x000, 0 }, + [284] = { 110, "bfsw2", 0x000, 0 }, + [285] = { 101, "wifism", 0x000, 0 }, + [286] = { 102, "wifisf", 0x000, 0 }, + [287] = { 111, "aji_peru", 0x006, 0 }, + [414] = { 209, "yadon", 0x000, 0 }, + [415] = { 201, "follower_mon", 0x225, 19 }, + [416] = { 202, "follower_mon_2", 0x225, 19 }, + [417] = { 203, "follower_mon_3", 0x225, 19 }, + [418] = { 204, "follower_mon_4", 0x225, 20 }, + [419] = { 205, "follower_mon_5", 0x225, 20 }, + [420] = { 206, "follower_mon_6", 0x225, 20 }, + [421] = { 59, "daigo", 0x000, 0 }, + [422] = { 60, "champion", 0x000, 0 }, + [423] = { 207, "shakehero", 0x260, 14 }, + [424] = { 208, "shakeheroine", 0x260, 14 }, + [425] = { 210, "hou_obj01", 0x1A5, 17 }, + [426] = { 211, "lug_obj01", 0x1A5, 18 }, + [427] = { 212, "gswoman6", 0x000, 0 }, + [126] = { 215, "leader1", 0x000, 0 }, + [127] = { 216, "leader2", 0x000, 0 }, + [128] = { 217, "leader3", 0x000, 0 }, + [129] = { 218, "leader4", 0x000, 0 }, + [130] = { 219, "leader5", 0x000, 0 }, + [131] = { 220, "leader6", 0x000, 0 }, + [132] = { 221, "leader7", 0x000, 0 }, + [133] = { 222, "leader8", 0x000, 0 }, + [233] = { 223, "gantetsu", 0x000, 0 }, + [380] = { 224, "jupetta", 0x000, 0 }, + [385] = { 225, "sakaki", 0x000, 0 }, + [386] = { 226, "suit", 0x000, 0 }, + [387] = { 227, "chourou", 0x000, 0 }, + [390] = { 228, "rapurasu", 0x000, 0 }, + [297] = { 248, "saverhero", 0x0A0, 16 }, + [298] = { 249, "saverheroine", 0x0A0, 16 }, + [299] = { 250, "delivery2", 0x000, 0 }, + [994] = { 309, "follower_mon_static_butterfree", 0x227, 19 }, + [995] = { 313, "follower_mon_static_pidgey", 0x227, 19 }, + [996] = { 316, "follower_mon_static_rattata", 0x227, 19 }, + [997] = { 318, "follower_mon_static_spearow", 0x227, 19 }, + [998] = { 319, "follower_mon_static_fearow", 0x227, 19 }, + [999] = { 322, "follower_mon_static_pikachu", 0x227, 19 }, + [1000] = { 327, "follower_mon_static_nidoran_f", 0x227, 19 }, + [1001] = { 330, "follower_mon_static_nidoran_m", 0x227, 19 }, + [1002] = { 331, "follower_mon_static_nidorino", 0x227, 19 }, + [1003] = { 333, "follower_mon_static_clefairy", 0x227, 19 }, + [1004] = { 337, "follower_mon_static_jigglypuff", 0x227, 19 }, + [1005] = { 339, "follower_mon_static_zubat", 0x227, 19 }, + [1006] = { 341, "follower_mon_static_oddish", 0x227, 19 }, + [1007] = { 348, "follower_mon_static_diglett", 0x226, 19 }, + [1008] = { 350, "follower_mon_static_meowth", 0x227, 19 }, + [1009] = { 351, "follower_mon_static_persian", 0x227, 19 }, + [1010] = { 352, "follower_mon_static_psyduck", 0x227, 19 }, + [1011] = { 360, "follower_mon_static_poliwrath", 0x227, 19 }, + [1012] = { 361, "follower_mon_static_abra", 0x227, 19 }, + [1013] = { 364, "follower_mon_static_machop", 0x227, 19 }, + [1014] = { 365, "follower_mon_static_machoke", 0x227, 19 }, + [1015] = { 377, "follower_mon_static_slowpoke", 0x227, 19 }, + [1016] = { 378, "follower_mon_static_slowbro", 0x227, 19 }, + [1017] = { 381, "follower_mon_static_farfetchd", 0x227, 19 }, + [1018] = { 383, "follower_mon_static_dodrio", 0x227, 19 }, + [1019] = { 399, "follower_mon_static_electrode", 0x227, 19 }, + [1020] = { 410, "follower_mon_static_rhydon", 0x227, 19 }, + [1021] = { 411, "follower_mon_static_chansey", 0x227, 19 }, + [1022] = { 413, "follower_mon_static_kangaskhan", 0x227, 19 }, + [1023] = { 429, "follower_mon_static_lapras", 0x227, 19 }, + [1024] = { 442, "follower_mon_static_articuno", 0x227, 19 }, + [1025] = { 443, "follower_mon_static_zapdos", 0x227, 19 }, + [1026] = { 444, "follower_mon_static_moltres", 0x227, 19 }, + [1027] = { 445, "follower_mon_static_dratini", 0x227, 19 }, + [1028] = { 447, "follower_mon_static_dragonite", 0x227, 19 }, + [1029] = { 448, "follower_mon_static_mewtwo", 0x227, 19 }, + [1030] = { 451, "follower_mon_static_bayleef", 0x227, 19 }, + [1031] = { 481, "follower_mon_static_ampharos", 0x227, 19 }, + [1032] = { 483, "follower_mon_static_marill", 0x227, 19 }, + [1033] = { 490, "follower_mon_static_aipom", 0x227, 19 }, + [1034] = { 498, "follower_mon_static_murkrow", 0x227, 19 }, + [1035] = { 571, "follower_mon_static_miltank", 0x227, 19 }, + [1036] = { 572, "follower_mon_static_blissey", 0x227, 19 }, + [1037] = { 573, "follower_mon_static_raikou", 0x227, 19 }, + [1038] = { 574, "follower_mon_static_entei", 0x227, 19 }, + [1039] = { 575, "follower_mon_static_suicune", 0x227, 19 }, + [1040] = { 581, "follower_mon_static_celebi", 0x227, 19 }, + [1041] = { 710, "follower_mon_static_latias", 0x227, 19 }, + [1042] = { 711, "follower_mon_static_latios", 0x227, 19 }, + [1043] = { 712, "follower_mon_static_kyogre", 0x208, 20 }, + [1044] = { 713, "follower_mon_static_groudon", 0x208, 20 }, + [1045] = { 714, "follower_mon_static_rayquaza", 0x208, 20 }, + [1046] = { 472, "follower_mon_static_pichu_spiky", 0x227, 19 }, + [1048] = { 456, "follower_mon_static_typhlosion", 0x227, 19 }, + [1047] = { 452, "follower_mon_static_meganium", 0x227, 19 }, + [1049] = { 459, "follower_mon_static_feraligatr", 0x227, 19 }, + }, +} diff --git a/src/import/Gen4HgssScriptAliases.lua b/src/import/Gen4HgssScriptAliases.lua new file mode 100644 index 000000000..e32a36040 --- /dev/null +++ b/src/import/Gen4HgssScriptAliases.lua @@ -0,0 +1,248 @@ +-- HEARTGOLD/SOULSILVER SCRIPT COMMANDS IN PLATINUM'S WORDS. +-- +-- Gen4ScriptVM lowers a command by its NAME (Platinum's, from pokeplatinum's +-- scrcmd.h). 116,050 of the 181,648 instructions in HeartGold's scripts +-- already carry a name Platinum's table uses with the same operands -- the two +-- games descend from Diamond and Pearl -- and those need nothing. This is the +-- rest: an HGSS command whose HANDLER does what a Platinum one does, read +-- side by side in pokeheartgold and pokeplatinum, renamed to that one so the +-- VM lowers it unchanged. +-- +-- An entry is the Platinum name, or { name, order } where `order` lists which +-- HGSS operands the Platinum command takes, in its order -- because a match in +-- meaning is not always a match in operands: +-- +-- TrainerBattle (wwbb) reads two trainers then two bytes Platinum's +-- starttrainerbattle (ww) does not have +-- GiveMon (species, level, item, form, ability, destVar) against +-- givepokemon (species, level, item, destVar) +-- WildBattle (species, level, byte) against startwildbattle (species, level) +-- +-- Every other entry has the same spec on both sides, which +-- tools/gen4_hgss_import_check.lua asserts for the whole table. +-- +-- NOOP is for what this port has no counterpart to and whose absence changes +-- nothing a script then reads: the walking partner Pokemon's movement, and the +-- touch-screen menu being shown or hidden around a message. +-- +-- Commands not here and not shared by name are left under their own name; +-- the VM reports them as unimplemented rather than guessing. + +local NOOP = "noop" + +local A = { + -- control and vars + wait = "waittime", + callstd = "callcommonscript", + restartcurrentscript = "returncommonscript", + setvar = "setvarfromvalue", -- ScriptGetVarPointer, ScriptReadHalfword + copyvar = "setvarfromvar", -- two var pointers + -- (destVar, ScriptGetVar src): var-or-literal, which is what the VM's copy + -- row already does with its source (Gen4Commands.valueOf) + setorcopyvar = "setvarfromvar", + nopvar490 = NOOP, + setflagvar = "setflagfromvar", + checkflagvar = "checkflagfromvar", + waitfade = "waitfadescreen", + random = "getrandom", -- (destVar, modulo) + + -- messages: the NPC / non-NPC pairs differ only in whether the speaker + -- turns, which Platinum's `message` row already does not model + npcmsg = "message", nonnpcmsg = "message", scrcmd_048 = "message", + npcmsgvar = "messagevar", nonnpcmsgvar = "messagevar", + openmsg = "openmessage", closemsg = "closemessage", + holdmsg = "closemessagewithouterasing", -- RemoveWindow, text left as it is + msgboxextern = "messagefrombank", -- (bank, entry), both GetVar + nonnpcmsgextern = "messagefrombank", + -- GenderMsgBox (male, female): its own row, Gen4ScriptVM's `gendermsgbox` + yesno = "showyesnomenu", + -- the touch-screen YES/NO: ov01_021F6ABC mode 3, and the scripts branch on + -- 0 for yes exactly as after showyesnomenu + getmenuchoice = "showyesnomenu", + + -- menus: sub_02041770 reads x, y, cursor, cancellable, var -- Platinum's + -- menu init exactly; NULL message data (the "Std" forms) is the standard + -- menu bank, as Platinum's global menus are + menuinitstdgmm = "initglobaltextmenu", menuinit = "initlocaltextmenu", + scrcmd_064 = "initglobaltextmenu", scrcmd_065 = "initlocaltextmenu", + scrcmd_068 = "initglobaltextlistmenu", scrcmd_069 = "initlocaltextlistmenu", + scrcmd_066 = "addmenuentryimm", scrcmd_067 = "showmenu", + scrcmd_070 = "addlistmenuentry", scrcmd_071 = "showlistmenu", + scrcmd_072 = "showmenumulticolumn", + menuitemadd = "addlistmenuentry", -- (msg, alt, value), all GetVar + menuexec = "showmenu", + touchscreenmenushow = NOOP, touchscreenmenuhide = NOOP, + + -- signs (Diamond/Pearl's block, one slot later in HGSS) + directionsignpost = "drawsignpostinstantmessage", + setsignpostmap = "drawsignposttextbox", + setsignpostaction = "setsignpostcommand", + waitsignpostaction = "waitforsignpostdone", + trainertips = "drawsignpostscrollingmessage", + waitsignpost = "getsignpostinput", + + -- objects and camera + lock = "lockobject", release = "releaseobject", + showperson = "addobject", hideperson = "removeobject", + getplayercoords = "getplayermappos", + getplayerfacing = "getplayerdir", + movepersonfacing = "setposition", -- (id, x, y, z, dir), all GetVar + scrcmd_102 = "addfreecamera", scrcmd_103 = "restorecamera", + scrcmd_107 = "movecamera", -- (x, y, z) -> FX32 shift + scrcmd_108 = "setobjectflagispersistent", scrcmd_109 = "setmovementtype", + setdynamicwarp = "setspeciallocation", -- (map, warp, x, y, dir) + -- the floor the dynamic warp names, as Platinum's elevator panel reads it + getdynamicwarpfloorno = "getfloorsabove", + makeobjectvisible = "showobject", + restoreoverworld = "returntofield", -- CallTask_RestoreOverworld + playeronbikecheck = "checkplayeronbike", playeronbikeset = "setplayerbike", + + -- the partner Pokemon walking behind the player + togglefollowingpokemonmovement = NOOP, waitfollowingpokemonmovement = NOOP, + followingpokemonmovement = NOOP, setfollowmoninhibitstate = NOOP, + scrcmd_609 = NOOP, -- nudges the partner if active + -- 605 (face the partner to the player, bb), 606 / 607 (partner permission + -- on this map), 608 (stop it): every one of them is guarded by + -- FollowMon_IsActive in scrcmd_c.c, and no partner walks in this port + scrcmd_605 = NOOP, scrcmd_606 = NOOP, scrcmd_607 = NOOP, scrcmd_608 = NOOP, + + -- DOORS. 307..311 are Platinum's door-animation family under numbers: + -- 307 (hw, hw, var, var, byte -- matrix cell, tile, slot) loads it, 310 + -- opens, 311 closes, 308 waits, 309 frees. Cherrygrove's guide walks home + -- through exactly `307 17,12,14,17,77 / 310 77 / 308 77`, then + -- `311 77 / 308 77 / 309 77` once he is inside. + scrcmd_307 = "loaddooranimation", scrcmd_308 = "waitforanimation", + scrcmd_309 = "unloadanimation", scrcmd_310 = "playdooropenanimation", + scrcmd_311 = "playdoorcloseanimation", + + -- the nickname prompt: (party slot var, result var), Platinum's operands + nicknameinput = "openpokemonnamingscreen", + + -- ScrCmd_796 (field/headbutt.c) closes the money box ScrCmd_795 opened + scrcmd_796 = "hidemoney", + -- A, B or a direction ends it; the message box already waits for the + -- button, so Platinum's waitbutton is the same row here + waitbuttonordpad = "waitbutton", + -- ScrCmd_795 (x, y) opens the money box: Platinum's ShowMoney operands + scrcmd_795 = "showmoney", + -- (dest var, amount var): money >= amount, as Platinum's second CheckMoney + hasenoughmoneyvar = "checkmoney2", + -- Save_SysInfo_SetMysteryGiftActive(.., 1): Oak's Pokedex scene turns the + -- title menu's MYSTERY GIFT on, which is Platinum's UnlockMysteryGift + scrcmd_573 = "unlockmysterygift", + + -- THE SHARED TRAINER SCRIPT. ScrCmd_574 is the object's + -- movement type, Platinum's GetMovementType in the same order; ScrCmd_454 + -- plays an overlay-26 effect on the last-talked object and changes nothing + -- a script reads; LoadPhoneDat is that trainer's phone- + -- book entry, and the shared trainer script (scr_seq_0953) tests it + -- against 255, NONE -- the answer while this port has no phone, which + -- keeps every trainer off the number-swap path (0 is a real entry: Mom) + scrcmd_574 = "getmovementtype", + scrcmd_454 = NOOP, + loadphonedat = { "setvarfromvalue", { 2, { 255 } } }, + -- clears a rematch's "seeking" flag in the phone state, which nothing + -- here keeps + scrcmd_462 = NOOP, + -- Morty's gym: the candle's light follows its medium every frame + -- (Gen4Commands' ecruteakFollow), so binding it for her walk is a no-op; + -- EcruteakGymInit and ExtinguishEcruteakGymCandle lower in Gen4ScriptVM + bindecruteakgymcandletotrainersprite = NOOP, + unbindecruteakgymcandlefromtrainersprite = NOOP, + + -- IN-GAME TRADES (Violet's Onix-for-Bellsprout first): the same five + -- steps as Platinum's, renamed -- load the trade, what they give, what + -- they want, swap, free; ScrCmd_566 is the party menu for it and + -- GetPartySelection its answer (255 when cancelled) + loadnpctrade = "initnpctrade", getofferedspecies = "getnpctradespecies", + npctradegetreqspecies = "getnpctraderequestedspecies", + npctradeexec = "startnpctrade", npctradeend = "finishnpctrade", + scrcmd_566 = "openpartymenufortrade", getpartyselection = "getselectedpartyslot", + -- Field_SetWarpXYPos / Field_GetTimeOfDay, Platinum's under other names + movewarp = "setwarpeventpos", + scrcmd_379 = "gettimeofday", + -- GetPhoneBookRematch : whether a registered trainer wants a + -- rematch today -- no phone is built, so never (0) + getphonebookrematch = { "setvarfromvalue", { 2, { 0 } } }, + -- GameStats_AddScore: the trainer card's SCORE for a stat (a gym win, ...), + -- not a game record; this port keeps no score yet + addspecialgamestat = NOOP, + -- "is the partner walking": never, in this port, so the answer is a + -- constant -- { k } in an order is a literal operand + scrcmd_729 = { "setvarfromvalue", { 1, { 0 } } }, -- TRUE when active + scrcmd_730 = { "setvarfromvalue", { 1, { 1 } } }, -- TRUE when NOT active + + -- string buffers + bufferplayersname = "bufferplayername", bufferrivalsname = "bufferrivalname", + bufferfriendsname = "buffercounterpartname", + buffermonspeciesname = "bufferpartymonspecies", -- (idx, partySlot) + bufferint = "buffernumber", bufferpartymonnick = "bufferpartymonnickname", + bufferspeciesname = "bufferspeciesnamefromvar", buffermapsecname = "buffermapname", + bufferpartymonmovename = "bufferpartymovename", + + -- items, money, coins, music + giveitem = "additem", takeitem = "removeitem", hasspaceforitem = "canfititem", + hasitem = "checkitem", itemistmorhm = "isitemtmhm", + getcoinamount = "getcoinsamount", givecoins = "addcoins", takecoins = "subtractcoinsfromvalue", + submoneyimmediate = "removemoney", hasenoughmoneyimmediate = "checkmoney", + showmoneybox = "showmoney", hidemoneybox = "hidemoney", updatemoneybox = "updatemoneydisplay", + playbgm = "playmusic", stopbgm = "stopmusic", resetbgm = "playdefaultmusic", tempbgm = "setbgm", + + -- party and player + getpartyleadalive = "getfirstnonegginparty", + checkbadge = "checkbadgeacquired", -- (badge, destVar) + getweekday = "getdayofweek", + savegamenormal = "trysavegame", + mongetfriendship = "getpartymonfriendship", -- (destVar, slot) + playerhasspecies = "checkpartyhasspecies", -- (destVar, species) + givemon = { "givepokemon", { 1, 2, 3, 6 } }, + + -- trainers and battles + trainerbattle = { "starttrainerbattle", { 1, 2 } }, + wildbattle = { "startwildbattle", { 1, 2 } }, + trainermessage = "printtrainerdialogue", + gettrainermsgparams = "gettrainermessagetypes", + getrematchmsgparams = "gettrainerrematchmessagetypes", + trainerisdoublebattle = "checkistrainerdoublebattle", + encountermusic = "playtrainerencounterbgm", + whiteout = "blackoutfrombattle", + checkbattlewon = "checkwonbattle", + gettrainernum = "gettrainerid", + partycheckfordouble = "checkhastwoalivemons", + gotoiftrainerdefeated = "gotoiftargettrainerdefeated", + gettrainerpathtoplayer = "startapproachingtrainertask", + trainersteptowardsplayer = "checkisapproachingtrainertaskdone", + gettrainereyetype = "getapproachingtrainertype", + geteyetrainernum = "getapproachingtrainerid", +} + +local M = { NOOP = NOOP, TABLE = A } + +-- the Platinum name and the operands it takes, or nil to leave the command be +function M.lookup(name) + local e = A[name] + if e == nil then return nil end + if type(e) == "string" then return e, nil end + return e[1], e[2] +end + +-- Rename an HGSS instruction in place; its own name is kept as `hgssName`. +-- A NOOP keeps no operands (the VM's `noop` reads none). +function M.apply(ins) + local name, order = M.lookup(ins.name) + if not name then return false end + ins.hgssName = ins.name + ins.name = name + if name == NOOP then + ins.args = {} + elseif order then + local args = {} + for i, k in ipairs(order) do + if type(k) == "table" then args[i] = k[1] else args[i] = ins.args and ins.args[k] end + end + ins.args = args + end + return true +end + +return M diff --git a/src/import/Gen4HgssScriptOps.lua b/src/import/Gen4HgssScriptOps.lua new file mode 100644 index 000000000..a9f1daa84 --- /dev/null +++ b/src/import/Gen4HgssScriptOps.lua @@ -0,0 +1,117 @@ +-- HEARTGOLD AND SOULSILVER'S SCRIPT COMMANDS: 853 of them, NOT Platinum's 840 +-- renumbered by a constant -- the two games diverge early (HGSS's 0x0E1 is +-- GoToIfTrainerDefeated) and never line up again, so a Platinum table fed an +-- HGSS script misreads its first unusual command and sails on. +-- +-- The same interface as Gen4ScriptOps, so Gen4Script decodes with either. +-- The widths are generated (src/import/Gen4HgssScriptTable.lua, from +-- pokeheartgold's asm/macros/script.inc -- what the scripts were assembled +-- with); this file is the five commands whose macros branch, each rule copied +-- from its macro: +-- +-- StrengthFlagAction / FlashAction / DefogAction (400-402): +-- .byte action, then .short var only when action == 2 -- Platinum's rule +-- ScrCmd_465: .short arg0, then two shorts when arg0 <= 3, none when it is +-- 6, one otherwise +-- MysteryGift (489): .short arg0, one short for 1..3, two for 5 and 6 + +local T = require("src.import.Gen4HgssScriptTable") + +local Ops = {} +Ops.FAMILY = "hgss" +Ops.OPCODE_BYTES = 2 +Ops.OPCODE_COUNT = T.count + +-- { name, spec } as Gen4ScriptOps has them; the name lowercased, which is the +-- spelling Platinum's table uses for the commands the two share +-- ("lockall", "faceplayer", "end") +Ops.COMMANDS = {} +Ops.MACRO_NAMES = {} +for op, entry in pairs(T.commands) do + Ops.COMMANDS[op] = { entry[1]:lower(), entry[2] } + Ops.MACRO_NAMES[op] = entry[1] +end + +Ops.VARIABLE_LENGTH = {} +for op, entry in pairs(T.commands) do + if entry[2] == "*" then Ops.VARIABLE_LENGTH[op] = entry[1]:lower() end +end + +Ops.TERMINATORS = { [0x002] = true, [0x01B] = true } -- End, Return + +-- The code jumps (Gen4Script resolves their targets). ApplyMovement's +-- relative operand points at movement data, and is resolved apart. +Ops.JUMPS = {} +for op in pairs(T.relative) do + if op ~= 0x05E then Ops.JUMPS[op] = Ops.COMMANDS[op][1] end +end +Ops.MOVEMENT = { [0x05E] = true } + +function Ops.name(op) + local entry = Ops.COMMANDS[op] + return entry and entry[1] or ("unknown_%03X"):format(op or -1) +end + +local function specWidth(spec) + local n = 0 + for i = 1, #spec do + local c = spec:sub(i, i) + n = n + (c == "b" and 1 or c == "w" and 2 or c == "d" and 4 or 0) + end + return n +end +Ops.specWidth = specWidth + +function Ops.size(op) + local entry = Ops.COMMANDS[op] + if not entry or entry[2]:find("*", 1, true) then return nil end + return Ops.OPCODE_BYTES + specWidth(entry[2]) +end + +local function u16At(data, at) + local lo, hi = data:byte(at, at + 1) + return lo and hi and lo + hi * 256 +end + +local function fieldMove(data, pc) + local action = data:byte(pc + Ops.OPCODE_BYTES) + if action == 2 then return "bw" end + if action == 0 or action == 1 then return "b" end + return nil +end + +Ops.VARIABLE_SPEC = { + [0x190] = fieldMove, [0x191] = fieldMove, [0x192] = fieldMove, + [0x1D1] = function(data, pc) + local a = u16At(data, pc + Ops.OPCODE_BYTES) + if not a then return nil end + if a <= 3 then return "www" end + if a == 6 then return "w" end + return "ww" + end, + [0x1E9] = function(data, pc) + local a = u16At(data, pc + Ops.OPCODE_BYTES) + if not a then return nil end + if a >= 1 and a <= 3 then return "ww" end + if a == 5 or a == 6 then return "www" end + return "w" + end, +} + +function Ops.specAt(op, data, pc) + local entry = Ops.COMMANDS[op] + if not entry then return nil end + local spec = entry[2] + if not spec:find("*", 1, true) then return spec end + local rule = Ops.VARIABLE_SPEC[op] + if not rule or type(data) ~= "string" or type(pc) ~= "number" then return nil end + return rule(data, pc) +end + +function Ops.sizeAt(op, data, pc) + local spec = Ops.specAt(op, data, pc) + if not spec then return nil end + return Ops.OPCODE_BYTES + specWidth(spec) +end + +return Ops diff --git a/src/import/Gen4HgssScriptTable.lua b/src/import/Gen4HgssScriptTable.lua new file mode 100644 index 000000000..51cd63c90 --- /dev/null +++ b/src/import/Gen4HgssScriptTable.lua @@ -0,0 +1,1720 @@ +-- GENERATED by tools/gen4_hgss_scrcmd_gen.py from pret/pokeheartgold +-- asm/macros/script.inc (the operand widths) and +-- src/data/fieldmap/script_cmd_table.h (the count). Do not edit; regenerate. +-- { macro name, lowercased name, spec }; "*" is variable (Gen4HgssScriptOps). +return { + count = 853, + handlers = { + [0] = "ScrCmd_Nop", + [1] = "ScrCmd_Dummy", + [2] = "ScrCmd_End", + [3] = "ScrCmd_Wait", + [4] = "ScrCmd_LoadByte", + [5] = "ScrCmd_LoadWord", + [6] = "ScrCmd_LoadByteFromAddr", + [7] = "ScrCmd_WriteByteToAddr", + [8] = "ScrCmd_SetPtrByte", + [9] = "ScrCmd_CopyLocal", + [10] = "ScrCmd_CopyByte", + [11] = "ScrCmd_CompareLocalToLocal", + [12] = "ScrCmd_CompareLocalToValue", + [13] = "ScrCmd_CompareLocalToAddr", + [14] = "ScrCmd_CompareAddrToLocal", + [15] = "ScrCmd_CompareAddrToValue", + [16] = "ScrCmd_CompareAddrToAddr", + [17] = "ScrCmd_CompareVarToValue", + [18] = "ScrCmd_CompareVarToVar", + [19] = "ScrCmd_RunScript", + [20] = "ScrCmd_CallStd", + [21] = "ScrCmd_RestartCurrentScript", + [22] = "ScrCmd_GoTo", + [23] = "ScrCmd_ObjectGoTo", + [24] = "ScrCmd_BGGoTo", + [25] = "ScrCmd_DirectionGoTo", + [26] = "ScrCmd_Call", + [27] = "ScrCmd_Return", + [28] = "ScrCmd_GoToIf", + [29] = "ScrCmd_CallIf", + [30] = "ScrCmd_SetFlag", + [31] = "ScrCmd_ClearFlag", + [32] = "ScrCmd_CheckFlag", + [33] = "ScrCmd_SetFlagVar", + [34] = "ScrCmd_ClearFlagVar", + [35] = "ScrCmd_CheckFlagVar", + [36] = "ScrCmd_SetTrainerFlag", + [37] = "ScrCmd_ClearTrainerFlag", + [38] = "ScrCmd_CheckTrainerFlag", + [39] = "ScrCmd_AddVar", + [40] = "ScrCmd_SubVar", + [41] = "ScrCmd_SetVar", + [42] = "ScrCmd_CopyVar", + [43] = "ScrCmd_SetOrCopyVar", + [44] = "ScrCmd_NonNPCMsg", + [45] = "ScrCmd_NPCMsg", + [46] = "ScrCmd_NonNPCMsgVar", + [47] = "ScrCmd_NPCMsgVar", + [48] = "ScrCmd_048", + [49] = "ScrCmd_WaitABPress", + [50] = "ScrCmd_WaitButton", + [51] = "ScrCmd_WaitButtonOrDpad", + [52] = "ScrCmd_OpenMsg", + [53] = "ScrCmd_CloseMsg", + [54] = "ScrCmd_HoldMsg", + [55] = "ScrCmd_DirectionSignpost", + [56] = "ScrCmd_SetSignpostMap", + [57] = "ScrCmd_SetSignpostAction", + [58] = "ScrCmd_WaitSignpostAction", + [59] = "ScrCmd_TrainerTips", + [60] = "ScrCmd_WaitSignpost", + [61] = "ScrCmd_061", + [62] = "ScrCmd_062", + [63] = "ScrCmd_YesNo", + [64] = "ScrCmd_064", + [65] = "ScrCmd_065", + [66] = "ScrCmd_066", + [67] = "ScrCmd_067", + [68] = "ScrCmd_068", + [69] = "ScrCmd_069", + [70] = "ScrCmd_070", + [71] = "ScrCmd_071", + [72] = "ScrCmd_072", + [73] = "ScrCmd_PlaySE", + [74] = "ScrCmd_StopSE", + [75] = "ScrCmd_WaitSE", + [76] = "ScrCmd_PlayCry", + [77] = "ScrCmd_WaitCry", + [78] = "ScrCmd_PlayFanfare", + [79] = "ScrCmd_WaitFanfare", + [80] = "ScrCmd_PlayBGM", + [81] = "ScrCmd_StopBGM", + [82] = "ScrCmd_ResetBGM", + [83] = "ScrCmd_083", + [84] = "ScrCmd_FadeOutBGM", + [85] = "ScrCmd_FadeInBGM", + [86] = "ScrCmd_086", + [87] = "ScrCmd_TempBGM", + [88] = "ScrCmd_088", + [89] = "ScrCmd_ChatotHasCry", + [90] = "ScrCmd_ChatotStartRecording", + [91] = "ScrCmd_ChatotStopRecording", + [92] = "ScrCmd_ChatotSaveRecording", + [93] = "ScrCmd_093", + [94] = "ScrCmd_ApplyMovement", + [95] = "ScrCmd_WaitMovement", + [96] = "ScrCmd_LockAll", + [97] = "ScrCmd_ReleaseAll", + [98] = "ScrCmd_Lock", + [99] = "ScrCmd_Release", + [100] = "ScrCmd_ShowPerson", + [101] = "ScrCmd_HidePerson", + [102] = "ScrCmd_102", + [103] = "ScrCmd_103", + [104] = "ScrCmd_FacePlayer", + [105] = "ScrCmd_GetPlayerCoords", + [106] = "ScrCmd_GetPersonCoords", + [107] = "ScrCmd_107", + [108] = "ScrCmd_108", + [109] = "ScrCmd_109", + [110] = "ScrCmd_AddMoney", + [111] = "ScrCmd_SubMoneyImmediate", + [112] = "ScrCmd_HasEnoughMoneyImmediate", + [113] = "ScrCmd_ShowMoneyBox", + [114] = "ScrCmd_HideMoneyBox", + [115] = "ScrCmd_UpdateMoneyBox", + [116] = "ScrCmd_116", + [117] = "ScrCmd_117", + [118] = "ScrCmd_118", + [119] = "ScrCmd_GetCoinAmount", + [120] = "ScrCmd_GiveCoins", + [121] = "ScrCmd_TakeCoins", + [122] = "ScrCmd_GiveAthletePoints", + [123] = "ScrCmd_TakeAthletePoints", + [124] = "ScrCmd_CheckAthletePoints", + [125] = "ScrCmd_GiveItem", + [126] = "ScrCmd_TakeItem", + [127] = "ScrCmd_HasSpaceForItem", + [128] = "ScrCmd_HasItem", + [129] = "ScrCmd_ItemIsTMOrHM", + [130] = "ScrCmd_GetItemPocket", + [131] = "ScrCmd_SetStarterChoice", + [132] = "ScrCmd_GenderMsgBox", + [133] = "ScrCmd_GetSealQuantity", + [134] = "ScrCmd_GiveOrTakeSeal", + [135] = "ScrCmd_GiveRandomSeal", + [136] = "ScrCmd_136", + [137] = "ScrCmd_GiveMon", + [138] = "ScrCmd_GiveEgg", + [139] = "ScrCmd_SetMonMove", + [140] = "ScrCmd_MonHasMove", + [141] = "ScrCmd_GetPartySlotWithMove", + [142] = "ScrCmd_GetPhoneBookRematch", + [143] = "ScrCmd_NameRival", + [144] = "ScrCmd_GetFriendSprite", + [145] = "ScrCmd_RegisterPokegearCard", + [146] = "ScrCmd_RegisterGearNumber", + [147] = "ScrCmd_CheckRegisteredPhoneNumber", + [148] = "ScrCmd_148", + [149] = "UnsetPhoneCallTrigger", + [150] = "ScrCmd_RestoreOverworld", + [151] = "ScrCmd_151", + [152] = "ScrCmd_152", + [153] = "ScrCmd_153", + [154] = "ScrCmd_154", + [155] = "ScrCmd_155", + [156] = "ScrCmd_156", + [157] = "ScrCmd_TownMap", + [158] = "ScrCmd_158", + [159] = "ScrCmd_159", + [160] = "ScrCmd_160", + [161] = "ScrCmd_161", + [162] = "ScrCmd_162", + [163] = "ScrCmd_HOFCredits", + [164] = "ScrCmd_164", + [165] = "ScrCmd_165", + [166] = "ScrCmd_166", + [167] = "ScrCmd_ChooseStarter", + [168] = "ScrCmd_GetTrainerPathToPlayer", + [169] = "ScrCmd_TrainerStepTowardsPlayer", + [170] = "ScrCmd_GetTrainerEyeType", + [171] = "ScrCmd_GetEyeTrainerNum", + [172] = "ScrCmd_NamePlayer", + [173] = "ScrCmd_NicknameInput", + [174] = "ScrCmd_FadeScreen", + [175] = "ScrCmd_WaitFade", + [176] = "ScrCmd_Warp", + [177] = "ScrCmd_RockClimb", + [178] = "ScrCmd_Surf", + [179] = "ScrCmd_Waterfall", + [180] = "ScrCmd_180", + [181] = "ScrCmd_FlashEffect", + [182] = "ScrCmd_Whirlpool", + [183] = "ScrCmd_183", + [184] = "ScrCmd_PlayerOnBikeCheck", + [185] = "ScrCmd_PlayerOnBikeSet", + [186] = "ScrCmd_SetBikeStateLock", + [187] = "ScrCmd_GetPlayerState", + [188] = "ScrCmd_SetAvatarBits", + [189] = "ScrCmd_UpdateAvatarState", + [190] = "ScrCmd_BufferPlayersName", + [191] = "ScrCmd_BufferRivalsName", + [192] = "ScrCmd_BufferFriendsName", + [193] = "ScrCmd_BufferMonSpeciesName", + [194] = "ScrCmd_BufferItemName", + [195] = "ScrCmd_BufferPocketName", + [196] = "ScrCmd_BufferTMHMMoveName", + [197] = "ScrCmd_BufferMoveName", + [198] = "ScrCmd_BufferInt", + [199] = "ScrCmd_BufferPartyMonNick", + [200] = "ScrCmd_BufferTrainerClassName", + [201] = "ScrCmd_BufferPlayerUnionAvatarClassName", + [202] = "ScrCmd_BufferSpeciesName", + [203] = "ScrCmd_BufferStarterSpeciesName", + [204] = "ScrCmd_BufferDPPtRivalStarterSpeciesName", + [205] = "ScrCmd_BufferDPPtFriendStarterSpeciesName", + [206] = "ScrCmd_GetStarterChoice", + [207] = "ScrCmd_BufferDecorationName", + [208] = "ScrCmd_208", + [209] = "ScrCmd_209", + [210] = "ScrCmd_BufferMapSecName", + [211] = "ScrCmd_211", + [212] = "ScrCmd_GetTrainerNum", + [213] = "ScrCmd_TrainerBattle", + [214] = "ScrCmd_TrainerMessage", + [215] = "ScrCmd_GetTrainerMsgParams", + [216] = "ScrCmd_GetRematchMsgParams", + [217] = "ScrCmd_TrainerIsDoubleBattle", + [218] = "ScrCmd_EncounterMusic", + [219] = "ScrCmd_WhiteOut", + [220] = "ScrCmd_CheckBattleWon", + [221] = "ScrCmd_StaticWildWonOrCaughtCheck", + [222] = "ScrCmd_PartyCheckForDouble", + [223] = "ScrCmd_223", + [224] = "ScrCmd_224", + [225] = "ScrCmd_GoToIfTrainerDefeated", + [226] = "ScrCmd_226", + [227] = "ScrCmd_227", + [228] = "ScrCmd_228", + [229] = "ScrCmd_229", + [230] = "ScrCmd_230", + [231] = "ScrCmd_231", + [232] = "ScrCmd_232", + [233] = "ScrCmd_233", + [234] = "ScrCmd_234", + [235] = "ScrCmd_235", + [236] = "ScrCmd_236", + [237] = "ScrCmd_237", + [238] = "ScrCmd_PartyHasPokerus", + [239] = "ScrCmd_MonGetGender", + [240] = "ScrCmd_SetDynamicWarp", + [241] = "ScrCmd_GetDynamicWarpFloorNo", + [242] = "ScrCmd_ElevatorCurFloorBox", + [243] = "ScrCmd_CountJohtoDexSeen", + [244] = "ScrCmd_CountJohtoDexOwned", + [245] = "ScrCmd_CountNationalDexSeen", + [246] = "ScrCmd_CountNationalDexOwned", + [247] = "ScrCmd_247", + [248] = "ScrCmd_GetDexEvalResult", + [249] = "ScrCmd_RocketTrapBattle", + [250] = "ScrCmd_250", + [251] = "ScrCmd_CatchingTutorial", + [252] = "ScrCmd_252", + [253] = "ScrCmd_GetSaveFileState", + [254] = "ScrCmd_SaveGameNormal", + [255] = "ScrCmd_255", + [256] = "ScrCmd_256", + [257] = "ScrCmd_257", + [258] = "ScrCmd_258", + [259] = "ScrCmd_259", + [260] = "ScrCmd_260", + [261] = "ScrCmd_261", + [262] = "ScrCmd_262", + [263] = "ScrCmd_263", + [264] = "ScrCmd_264", + [265] = "ScrCmd_265", + [266] = "ScrCmd_266", + [267] = "ScrCmd_267", + [268] = "ScrCmd_268", + [269] = "ScrCmd_269", + [270] = "ScrCmd_270", + [271] = "ScrCmd_271", + [272] = "ScrCmd_272", + [273] = "ScrCmd_273", + [274] = "ScrCmd_274", + [275] = "ScrCmd_MartBuy", + [276] = "ScrCmd_SpecialMartBuy", + [277] = "ScrCmd_DecorationMart", + [278] = "ScrCmd_SealMart", + [279] = "ScrCmd_OverworldWhiteOut", + [280] = "ScrCmd_SetSpawn", + [281] = "ScrCmd_GetPlayerGender", + [282] = "ScrCmd_HealParty", + [283] = "ScrCmd_283", + [284] = "ScrCmd_284", + [285] = "ScrCmd_285", + [286] = "ScrCmd_286", + [287] = "ScrCmd_BufferUnionRoomAvatarChoices", + [288] = "ScrCmd_UnionRoomAvatarIdxToTrainerClass", + [289] = "ScrCmd_289", + [290] = "ScrCmd_CheckPokedex", + [291] = "ScrCmd_GivePokedex", + [292] = "ScrCmd_CheckRunningShoes", + [293] = "ScrCmd_GiveRunningShoes", + [294] = "ScrCmd_CheckBadge", + [295] = "ScrCmd_GiveBadge", + [296] = "ScrCmd_CountBadges", + [297] = "ScrCmd_297", + [298] = "ScrCmd_298", + [299] = "ScrCmd_CheckEscortMode", + [300] = "ScrCmd_SetEscortMode", + [301] = "ScrCmd_ClearEscortMode", + [302] = "ScrCmd_CheckStepTakenFlag", + [303] = "ScrCmd_SetStepTakenFlag", + [304] = "ScrCmd_GetStepTakenFlag", + [305] = "ScrCmd_CheckGameClearFlag", + [306] = "ScrCmd_SetGameClearFlag", + [307] = "ScrCmd_307", + [308] = "ScrCmd_308", + [309] = "ScrCmd_309", + [310] = "ScrCmd_310", + [311] = "ScrCmd_311", + [312] = "ScrCmd_BufferDaycareMonNicks", + [313] = "ScrCmd_GetDaycareState", + [314] = "ScrCmd_EcruteakGymInit", + [315] = "ScrCmd_BindEcruteakGymCandleToTrainerSprite", + [316] = "ScrCmd_UnbindEcruteakGymCandleFromTrainerSprite", + [317] = "ScrCmd_ExtinguishEcruteakGymCandle", + [318] = "ScrCmd_CianwoodGymInit", + [319] = "ScrCmd_CianwoodGymTurnWinch", + [320] = "ScrCmd_VermilionGymInit", + [321] = "ScrCmd_VermilionGymLockAction", + [322] = "ScrCmd_VermilionGymCanCheck", + [323] = "ScrCmd_ResampleVermilionGymCans", + [324] = "ScrCmd_VioletGymInit", + [325] = "ScrCmd_VioletGymElevator", + [326] = "ScrCmd_AzaleaGymInit", + [327] = "ScrCmd_AzaleaGymSpinarak", + [328] = "ScrCmd_AzaleaGymSwitch", + [329] = "ScrCmd_BlackthornGymInit", + [330] = "ScrCmd_FuchsiaGymInit", + [331] = "ScrCmd_ViridianGymInit", + [332] = "ScrCmd_GetPartyCount", + [333] = "ScrCmd_333", + [334] = "ScrCmd_334", + [335] = "ScrCmd_335", + [336] = "ScrCmd_BufferBerryName", + [337] = "ScrCmd_BufferNatureName", + [338] = "ScrCmd_MovePerson", + [339] = "ScrCmd_MovePersonFacing", + [340] = "ScrCmd_SetObjectMovementType", + [341] = "ScrCmd_SetObjectFacing", + [342] = "ScrCmd_MoveWarp", + [343] = "ScrCmd_MoveBGEvent", + [344] = "ScrCmd_344", + [345] = "ScrCmd_AddWaitingIcon", + [346] = "ScrCmd_RemoveWaitingIcon", + [347] = "ScrCmd_347", + [348] = "ScrCmd_WaitButtonOrDelay", + [349] = "ScrCmd_PartySelectUI", + [350] = "ScrCmd_350", + [351] = "ScrCmd_GetPartySelection", + [352] = "ScrCmd_PokemonSummaryScreen", + [353] = "ScrCmd_GetMoveSelection", + [354] = "ScrCmd_GetPartyMonSpecies", + [355] = "ScrCmd_PartyMonIsMine", + [356] = "ScrCmd_PartyCountNotEgg", + [357] = "ScrCmd_CountAliveMons", + [358] = "ScrCmd_CountAliveMonsAndPC", + [359] = "ScrCmd_PartyCountEgg", + [360] = "ScrCmd_SubMoneyVar", + [361] = "ScrCmd_RetrieveDaycareMon", + [362] = "ScrCmd_GiveLoanMon", + [363] = "ScrCmd_CheckReturnLoanMon", + [364] = "ScrCmd_ReturnLoanMon", + [365] = "ScrCmd_ResetDaycareEgg", + [366] = "ScrCmd_GiveDaycareEgg", + [367] = "ScrCmd_BufferDaycareWithdrawCost", + [368] = "ScrCmd_HasEnoughMoneyVar", + [369] = "ScrCmd_EggHatchAnim", + [370] = "ScrCmd_370", + [371] = "ScrCmd_BufferDaycareMonGrowth", + [372] = "ScrCmd_GetTailDaycareMonSpeciesAndNick", + [373] = "ScrCmd_PutMonInDaycare", + [374] = "ScrCmd_374", + [375] = "ScrCmd_MakeObjectVisible", + [376] = "ScrCmd_376", + [377] = "ScrCmd_377", + [378] = "ScrCmd_ViewRankings", + [379] = "ScrCmd_379", + [380] = "ScrCmd_Random", + [381] = "ScrCmd_381", + [382] = "ScrCmd_MonGetFriendship", + [383] = "ScrCmd_MonAddFriendship", + [384] = "ScrCmd_MonSubtractFriendship", + [385] = "ScrCmd_BufferDaycareMonStats", + [386] = "ScrCmd_GetPlayerFacing", + [387] = "ScrCmd_GetDaycareCompatibility", + [388] = "ScrCmd_CheckDaycareEgg", + [389] = "ScrCmd_PlayerHasSpecies", + [390] = "ScrCmd_SizeRecordCompare", + [391] = "ScrCmd_SizeRecordUpdate", + [392] = "ScrCmd_BufferMonSize", + [393] = "ScrCmd_BufferRecordSize", + [394] = "ScrCmd_394", + [395] = "ScrCmd_395", + [396] = "ScrCmd_CountMonMoves", + [397] = "ScrCmd_MonForgetMove", + [398] = "ScrCmd_MonGetMove", + [399] = "ScrCmd_BufferPartyMonMoveName", + [400] = "ScrCmd_StrengthFlagAction", + [401] = "ScrCmd_FlashAction", + [402] = "ScrCmd_DefogAction", + [403] = "ScrCmd_403", + [404] = "ScrCmd_404", + [405] = "ScrCmd_405", + [406] = "ScrCmd_406", + [407] = "ScrCmd_407", + [408] = "ScrCmd_408", + [409] = "ScrCmd_409", + [410] = "ScrCmd_410", + [411] = "ScrCmd_411", + [412] = "ScrCmd_412", + [413] = "ScrCmd_413", + [414] = "ScrCmd_414", + [415] = "ScrCmd_415", + [416] = "ScrCmd_416", + [417] = "ScrCmd_417", + [418] = "ScrCmd_418", + [419] = "ScrCmd_419", + [420] = "ScrCmd_420", + [421] = "ScrCmd_421", + [422] = "ScrCmd_422", + [423] = "ScrCmd_CheckJohtoDexComplete", + [424] = "ScrCmd_CheckNationalDexComplete", + [425] = "ScrCmd_ShowCertificate", + [426] = "ScrCmd_KenyaCheck", + [427] = "ScrCmd_427", + [428] = "ScrCmd_MonGiveMail", + [429] = "ScrCmd_CountFossils", + [430] = "ScrCmd_SetPhoneCall", + [431] = "ScrCmd_RunPhoneCall", + [432] = "ScrCmd_GetFossilPokemon", + [433] = "ScrCmd_GetFossilMinimumAmount", + [434] = "ScrCmd_PartyCountMonsAtOrBelowLevel", + [435] = "ScrCmd_SurvivePoisoning", + [436] = "ScrCmd_436", + [437] = "ScrCmd_DebugWatch", + [438] = "ScrCmd_GetStdMsgNaix", + [439] = "ScrCmd_NonNPCMsgExtern", + [440] = "ScrCmd_MsgBoxExtern", + [441] = "ScrCmd_441", + [442] = "ScrCmd_442", + [443] = "ScrCmd_443", + [444] = "ScrCmd_444", + [445] = "ScrCmd_445", + [446] = "ScrCmd_446", + [447] = "ScrCmd_SafariZoneAction", + [448] = "ScrCmd_448", + [449] = "ScrCmd_449", + [450] = "ScrCmd_450", + [451] = "ScrCmd_451", + [452] = "ScrCmd_452", + [453] = "ScrCmd_453", + [454] = "ScrCmd_454", + [455] = "ScrCmd_455", + [456] = "ScrCmd_456", + [457] = "ScrCmd_MonGetNature", + [458] = "ScrCmd_GetPartySlotWithNature", + [459] = "ScrCmd_459", + [460] = "ScrCmd_LoadPhoneDat", + [461] = "ScrCmd_GetPhoneContactMsgIds", + [462] = "ScrCmd_462", + [463] = "ScrCmd_EnableMassOutbreaks", + [464] = "ScrCmd_CreateRoamer", + [465] = "ScrCmd_465", + [466] = "ScrCmd_466", + [467] = "ScrCmd_MoveRelearnerInit", + [468] = "ScrCmd_MoveTutorInit", + [469] = "ScrCmd_MoveRelearnerGetResult", + [470] = "ScrCmd_LoadNPCTrade", + [471] = "ScrCmd_GetOfferedSpecies", + [472] = "ScrCmd_NPCTradeGetReqSpecies", + [473] = "ScrCmd_NPCTradeExec", + [474] = "ScrCmd_NPCTradeEnd", + [475] = "ScrCmd_475", + [476] = "ScrCmd_EnablePokedexFormDetection", + [477] = "ScrCmd_NatDexFlagAction", + [478] = "ScrCmd_MonGetRibbonCount", + [479] = "ScrCmd_GetPartyRibbonCount", + [480] = "ScrCmd_MonHasRibbon", + [481] = "ScrCmd_GiveRibbon", + [482] = "ScrCmd_BufferRibbonName", + [483] = "ScrCmd_GetEVTotal", + [484] = "ScrCmd_GetWeekday", + [485] = "ScrCmd_StartBattleRegulationMenuTask", + [486] = "ScrCmd_Dummy", + [487] = "ScrCmd_PokeCenAnim", + [488] = "ScrCmd_ElevatorAnim", + [489] = "ScrCmd_MysteryGift", + [490] = "ScrCmd_NopVar490", + [491] = "ScrCmd_491", + [492] = "ScrCmd_492", + [493] = "ScrCmd_PromptEasyChat", + [494] = "ScrCmd_494", + [495] = "ScrCmd_GetGameVersion", + [496] = "ScrCmd_GetPartyLead", + [497] = "ScrCmd_GetMonTypes", + [498] = "ScrCmd_PrimoPasswordCheck1", + [499] = "ScrCmd_PrimoPasswordCheck2", + [500] = "ScrCmd_500", + [501] = "ScrCmd_501", + [502] = "ScrCmd_502", + [503] = "ScrCmd_LotoIDGet", + [504] = "ScrCmd_LotoIDSearch", + [505] = "ScrCmd_LotoIDSet", + [506] = "ScrCmd_BufferBoxMonNick", + [507] = "ScrCmd_CountPCEmptySpace", + [508] = "ScrCmd_PalParkAction", + [509] = "ScrCmd_509", + [510] = "ScrCmd_510", + [511] = "ScrCmd_PalParkScoreGet", + [512] = "ScrCmd_PlayerMovementSavingSet", + [513] = "ScrCmd_PlayerMovementSavingClear", + [514] = "ScrCmd_HallOfFameAnim", + [515] = "ScrCmd_AddSpecialGameStat", + [516] = "ScrCmd_BufferFashionName", + [517] = "ScrCmd_517", + [518] = "ScrCmd_518", + [519] = "ScrCmd_519", + [520] = "ScrCmd_520", + [521] = "ScrCmd_521", + [522] = "ScrCmd_522", + [523] = "ScrCmd_523", + [524] = "ScrCmd_524", + [525] = "ScrCmd_525", + [526] = "ScrCmd_526", + [527] = "ScrCmd_527", + [528] = "ScrCmd_528", + [529] = "ScrCmd_GetPartyLeadAlive", + [530] = "ScrCmd_530", + [531] = "ScrCmd_BufferBackgroundName", + [532] = "ScrCmd_CheckCoinsImmediate", + [533] = "ScrCmd_CheckGiveCoins", + [534] = "ScrCmd_534", + [535] = "ScrCmd_MonGetLevel", + [536] = "ScrCmd_536", + [537] = "ScrCmd_537", + [538] = "ScrCmd_538", + [539] = "ScrCmd_539", + [540] = "ScrCmd_540", + [541] = "ScrCmd_BufferIntEx", + [542] = "ScrCmd_MonGetContestValue", + [543] = "ScrCmd_543", + [544] = "ScrCmd_544", + [545] = "ScrCmd_545", + [546] = "ScrCmd_546", + [547] = "ScrCmd_547", + [548] = "ScrCmd_548", + [549] = "ScrCmd_549", + [550] = "ScrCmd_550", + [551] = "ScrCmd_551", + [552] = "ScrCmd_552", + [553] = "ScrCmd_553", + [554] = "ScrCmd_554", + [555] = "ScrCmd_555", + [556] = "ScrCmd_556", + [557] = "ScrCmd_CheckBattlePoints", + [558] = "ScrCmd_UnionRoomAvatarIdxToSprite", + [559] = "ScrCmd_559", + [560] = "ScrCmd_560", + [561] = "ScrCmd_ScreenShake", + [562] = "ScrCmd_MultiBattle", + [563] = "ScrCmd_563", + [564] = "ScrCmd_564", + [565] = "ScrCmd_565", + [566] = "ScrCmd_566", + [567] = "ScrCmd_GetDPPlPrizeItemIDAndCost", + [568] = "ScrCmd_568", + [569] = "ScrCmd_569", + [570] = "ScrCmd_CheckCoinsVar", + [571] = "ScrCmd_571", + [572] = "ScrCmd_GetUniqueSealsQuantity", + [573] = "ScrCmd_573", + [574] = "ScrCmd_574", + [575] = "ScrCmd_575", + [576] = "ScrCmd_576", + [577] = "ScrCmd_577", + [578] = "ScrCmd_578", + [579] = "ScrCmd_579", + [580] = "ScrCmd_BufferSealName", + [581] = "ScrCmd_LockLastTalked", + [582] = "ScrCmd_582", + [583] = "ScrCmd_583", + [584] = "ScrCmd_PartyLegalCheck", + [585] = "ScrCmd_585", + [586] = "ScrCmd_586", + [587] = "ScrCmd_587", + [588] = "ScrCmd_LatiCaughtCheck", + [589] = "ScrCmd_WildBattle", + [590] = "ScrCmd_GetTrcardStars", + [591] = "ScrCmd_591", + [592] = "ScrCmd_592", + [593] = "ScrCmd_ShowSaveStats", + [594] = "ScrCmd_HideSaveStats", + [595] = "ScrCmd_595", + [596] = "ScrCmd_596", + [597] = "ScrCmd_597", + [598] = "ScrCmd_598", + [599] = "ScrCmd_599", + [600] = "ScrCmd_600", + [601] = "ScrCmd_FollowMonFacePlayer", + [602] = "ScrCmd_ToggleFollowingPokemonMovement", + [603] = "ScrCmd_WaitFollowingPokemonMovement", + [604] = "ScrCmd_FollowingPokemonMovement", + [605] = "ScrCmd_605", + [606] = "ScrCmd_606", + [607] = "ScrCmd_607", + [608] = "ScrCmd_608", + [609] = "ScrCmd_609", + [610] = "ScrCmd_610", + [611] = "ScrCmd_Pokeathlon", + [612] = "ScrCmd_GetNPCTradeUnusedFlag", + [613] = "ScrCmd_GetPhoneContactRandomGiftBerry", + [614] = "ScrCmd_GetPhoneContactGiftItem", + [615] = "ScrCmd_CameronPhoto", + [616] = "ScrCmd_CountSavedPhotos", + [617] = "ScrCmd_OpenPhotoAlbum", + [618] = "ScrCmd_PhotoAlbumIsFull", + [619] = "ScrCmd_RocketCostumeFlagCheck", + [620] = "ScrCmd_RocketCostumeFlagAction", + [621] = "ScrCmd_PlaceStarterBallsInElmsLab", + [622] = "ScrCmd_622", + [623] = "ScrCmd_AnimApricornTree", + [624] = "ScrCmd_ApricornTreeGetApricorn", + [625] = "ScrCmd_GiveApricornFromTree", + [626] = "ScrCmd_BufferApricornName", + [627] = "ScrCmd_627", + [628] = "ScrCmd_628", + [629] = "ScrCmd_629", + [630] = "ScrCmd_630", + [631] = "ScrCmd_631", + [632] = "ScrCmd_CountPartyMonsOfSpecies", + [633] = "ScrCmd_633", + [634] = "ScrCmd_634", + [635] = "ScrCmd_635", + [636] = "ScrCmd_636", + [637] = "ScrCmd_637", + [638] = "ScrCmd_638", + [639] = "ScrCmd_639", + [640] = "ScrCmd_640", + [641] = "ScrCmd_SaveWipeExtraChunks", + [642] = "ScrCmd_642", + [643] = "ScrCmd_643", + [644] = "ScrCmd_644", + [645] = "ScrCmd_645", + [646] = "ScrCmd_646", + [647] = "ScrCmd_GetPartySlotWithSpecies", + [648] = "ScrCmd_648", + [649] = "ScrCmd_ScratchOffCard", + [650] = "ScrCmd_ScratchOffCardEnd", + [651] = "ScrCmd_GetScratchOffPrize", + [652] = "ScrCmd_652", + [653] = "ScrCmd_MoveTutorChooseMove", + [654] = "ScrCmd_TutorMoveTeachInSlot", + [655] = "ScrCmd_TutorMoveGetPrice", + [656] = "ScrCmd_656", + [657] = "ScrCmd_StatJudge", + [658] = "ScrCmd_BufferStatName", + [659] = "ScrCmd_SetMonForm", + [660] = "ScrCmd_BufferTrainerName", + [661] = "ScrCmd_661", + [662] = "ScrCmd_662", + [663] = "ScrCmd_663", + [664] = "ScrCmd_664", + [665] = "ScrCmd_665", + [666] = "ScrCmd_666", + [667] = "ScrCmd_667", + [668] = "ScrCmd_BufferTypeName", + [669] = "ScrCmd_GetItemQuantity", + [670] = "ScrCmd_GetHiddenPowerType", + [671] = "ScrCmd_SetFavoriteMon", + [672] = "ScrCmd_GetFavoriteMon", + [673] = "ScrCmd_GetOwnedRotomForms", + [674] = "ScrCmd_CountTranformedRotomsInParty", + [675] = "ScrCmd_UpdateRotomForm", + [676] = "ScrCmd_GetPartyMonForm", + [677] = "ScrCmd_677", + [678] = "ScrCmd_678", + [679] = "ScrCmd_679", + [680] = "ScrCmd_AddSpecialGameStat2", + [681] = "ScrCmd_681", + [682] = "ScrCmd_682", + [683] = "ScrCmd_GetStaticEncounterOutcome", + [684] = "ScrCmd_684", + [685] = "ScrCmd_GetPlayerXYZ", + [686] = "ScrCmd_686", + [687] = "ScrCmd_687", + [688] = "ScrCmd_GetPartySlotWithFatefulEncounter", + [689] = "ScrCmd_CommSanitizeParty", + [690] = "ScrCmd_DaycareSanitizeMon", + [691] = "ScrCmd_691", + [692] = "ScrCmd_BufferBattleHallStreak", + [693] = "ScrCmd_BattleHallCountUsedSpecies", + [694] = "ScrCmd_BattleHallGetTotalStreak", + [695] = "ScrCmd_695", + [696] = "ScrCmd_696", + [697] = "ScrCmd_697", + [698] = "ScrCmd_FollowerPokeIsEventTrigger", + [699] = "ScrCmd_699", + [700] = "ScrCmd_700", + [701] = "ScrCmd_MonHasItem", + [702] = "ScrCmd_BattleTowerSetUpMultiBattle", + [703] = "ScrCmd_SetPlayerVolume", + [704] = "ScrCmd_704", + [705] = "ScrCmd_705", + [706] = "ScrCmd_706", + [707] = "ScrCmd_CheckMonSeen", + [708] = "ScrCmd_708", + [709] = "ScrCmd_709", + [710] = "ScrCmd_710", + [711] = "ScrCmd_FollowMonInteract", + [712] = "ScrCmd_712", + [713] = "ScrCmd_AlphPuzzle", + [714] = "ScrCmd_OpenAlphHiddenRoom", + [715] = "ScrCmd_UpdateDaycareMonObjects", + [716] = "ScrCmd_716", + [717] = "ScrCmd_717", + [718] = "ScrCmd_718", + [719] = "ScrCmd_719", + [720] = "ScrCmd_720", + [721] = "ScrCmd_721", + [722] = "ScrCmd_722", + [723] = "ScrCmd_723", + [724] = "ScrCmd_724", + [725] = "ScrCmd_725", + [726] = "ScrCmd_ProcessSoundplate", + [727] = "ScrCmd_GetFollowPokePartyIndex", + [728] = "ScrCmd_728", + [729] = "ScrCmd_729", + [730] = "ScrCmd_730", + [731] = "ScrCmd_731", + [732] = "ScrCmd_732", + [733] = "ScrCmd_733", + [734] = "ScrCmd_734", + [735] = "ScrCmd_735", + [736] = "ScrCmd_ClearKurtApricorn", + [737] = "ScrCmd_737", + [738] = "ScrCmd_GetTotalApricornCount", + [739] = "ScrCmd_739", + [740] = "ScrCmd_740", + [741] = "ScrCmd_741", + [742] = "ScrCmd_742", + [743] = "ScrCmd_743", + [744] = "ScrCmd_CreatePokeathlonFriendshipRoomStatues", + [745] = "ScrCmd_BufferPokeathlonCourseName", + [746] = "ScrCmd_TouchscreenMenuHide", + [747] = "ScrCmd_TouchscreenMenuShow", + [748] = "ScrCmd_GetMenuChoice", + [749] = "ScrCmd_MenuInitStdGmm", + [750] = "ScrCmd_MenuInit", + [751] = "ScrCmd_MenuItemAdd", + [752] = "ScrCmd_MenuExec", + [753] = "ScrCmd_RockSmashItemCheck", + [754] = "ScrCmd_TryHeadbuttEncounter", + [755] = "ScrCmd_LegendCutsceneClearBellAnimBegin", + [756] = "ScrCmd_LegendCutsceneClearBellAnimEnd", + [757] = "ScrCmd_LegendCutsceneClearBellRiseFromBag", + [758] = "ScrCmd_LegendCutsceneClearBellShimmer", + [759] = "ScrCmd_LegendCutsceneLugiaEyeGlimmerEffect", + [760] = "ScrCmd_760", + [761] = "ScrCmd_LegendCutsceneMoveCameraTo", + [762] = "ScrCmd_LegendCutscenePanCameraTo", + [763] = "ScrCmd_LegendCutsceneWaitCameraPan", + [764] = "ScrCmd_LegendCutsceneBirdFinalApproach", + [765] = "ScrCmd_LegendCutsceneWavesOrLeavesEffectBegin", + [766] = "ScrCmd_LegendCutsceneWavesOrLeavesEffectEnd", + [767] = "ScrCmd_LegendCutsceneLugiaArrivesEffectBegin", + [768] = "ScrCmd_LegendCutsceneLugiaArrivesEffectEnd", + [769] = "ScrCmd_LegendCutsceneLugiaArrivesEffectCameraPan", + [770] = "ScrCmd_CheckSeenAllLetterUnown", + [771] = "ScrCmd_771", + [772] = "ScrCmd_772", + [773] = "ScrCmd_Cinematic", + [774] = "ScrCmd_ShowLegendaryWing", + [775] = "ScrCmd_775", + [776] = "ScrCmd_GiveTogepiEgg", + [777] = "ScrCmd_777", + [778] = "ScrCmd_GiveSpikyEarPichu", + [779] = "ScrCmd_RadioMusicIsPlaying", + [780] = "ScrCmd_CasinoGame", + [781] = "ScrCmd_KenyaCheckPartyOrMailbox", + [782] = "ScrCmd_MartSell", + [783] = "ScrCmd_SetFollowMonInhibitState", + [784] = "ScrCmd_ScriptOverlayCmd", + [785] = "ScrCmd_BugContestAction", + [786] = "ScrCmd_BufferBugContestWinner", + [787] = "ScrCmd_JudgeBugContest", + [788] = "ScrCmd_BufferBugContestMonNick", + [789] = "ScrCmd_BugContestGetTimeLeft", + [790] = "ScrCmd_IsBugContestantRegistered", + [791] = "ScrCmd_CheckSafariZoneChallengeCompleted", + [792] = "ScrCmd_UpdateSafariZoneIGT", + [793] = "ScrCmd_BankTransaction", + [794] = "ScrCmd_CheckBankBalance", + [795] = "ScrCmd_795", + [796] = "ScrCmd_796", + [797] = "ScrCmd_797", + [798] = "ScrCmd_BufferRulesetName", + [799] = "ScrCmd_799", + [800] = "ScrCmd_800", + [801] = "ScrCmd_801", + [802] = "ScrCmd_802", + [803] = "ScrCmd_803", + [804] = "ScrCmd_804", + [805] = "ScrCmd_805", + [806] = "ScrCmd_806", + [807] = "ScrCmd_SetTrainerHouseSprite", + [808] = "ScrCmd_808", + [809] = "ScrCmd_ShowTrainerHouseIntroMessage", + [810] = "ScrCmd_810", + [811] = "ScrCmd_811", + [812] = "ScrCmd_812", + [813] = "ScrCmd_MomGiftCheck", + [814] = "ScrCmd_814", + [815] = "ScrCmd_815", + [816] = "ScrCmd_UnownCircle", + [817] = "ScrCmd_817", + [818] = "ScrCmd_MystriStageGymmickInit", + [819] = "ScrCmd_819", + [820] = "ScrCmd_820", + [821] = "ScrCmd_GetBuenasPassword", + [822] = "ScrCmd_822", + [823] = "ScrCmd_823", + [824] = "ScrCmd_824", + [825] = "ScrCmd_GetShinyLeafCount", + [826] = "ScrCmd_TryGiveShinyLeafCrown", + [827] = "ScrCmd_GetPartyMonForm2", + [828] = "ScrCmd_MonAddContestValue", + [829] = "ScrCmd_829", + [830] = "ScrCmd_830", + [831] = "ScrCmd_831", + [832] = "ScrCmd_832", + [833] = "ScrCmd_833", + [834] = "ScrCmd_834", + [835] = "ScrCmd_835", + [836] = "ScrCmd_CheckKyogreGroudonInParty", + [837] = "ScrCmd_837", + [838] = "ScrCmd_BankOrWalletIsFull", + [839] = "ScrCmd_SysSetSleepFlag", + [840] = "ScrCmd_840", + [841] = "ScrCmd_841", + [842] = "ScrCmd_842", + [843] = "ScrCmd_BufferItemNameIndef", + [844] = "ScrCmd_BufferItemNamePlural", + [845] = "ScrCmd_BufferPartyMonSpeciesNameIndef", + [846] = "ScrCmd_BufferSpeciesNameIndef", + [847] = "ScrCmd_BufferDPPtFriendStarterSpeciesNameIndef", + [848] = "ScrCmd_BufferFashionNameIndef", + [849] = "ScrCmd_BufferTrainerClassNameIndef", + [850] = "ScrCmd_BufferSealNamePlural", + [851] = "ScrCmd_Capitalize", + [852] = "ScrCmd_BufferDeptStoreFloorNo", + }, + commands = { + [0x000] = { "Noop", "" }, + [0x001] = { "Dummy", "" }, + [0x002] = { "End", "" }, + [0x003] = { "Wait", "ww" }, + [0x004] = { "LoadByte", "bb" }, + [0x005] = { "LoadWord", "bd" }, + [0x006] = { "LoadByteFromAddr", "bd" }, + [0x007] = { "WriteByteToAddr", "db" }, + [0x008] = { "SetPtrByte", "db" }, + [0x009] = { "CopyLocal", "bb" }, + [0x00A] = { "CopyByte", "dd" }, + [0x00B] = { "CompareLocalToLocal", "bb" }, + [0x00C] = { "CompareLocalToValue", "bb" }, + [0x00D] = { "CompareLocalToAddr", "bd" }, + [0x00E] = { "CompareAddrToLocal", "db" }, + [0x00F] = { "CompareAddrToValue", "db" }, + [0x010] = { "CompareAddrToAddr", "dd" }, + [0x011] = { "CompareVarToValue", "ww" }, + [0x012] = { "CompareVarToVar", "ww" }, + [0x013] = { "RunScript", "w" }, + [0x014] = { "CallStd", "w" }, + [0x015] = { "RestartCurrentScript", "" }, + [0x016] = { "GoTo", "d" }, + [0x017] = { "ObjectGoTo", "bd" }, + [0x018] = { "BGGoTo", "bd" }, + [0x019] = { "DirectionGoTo", "bd" }, + [0x01A] = { "Call", "d" }, + [0x01B] = { "Return", "" }, + [0x01C] = { "GoToIf", "bd" }, + [0x01D] = { "CallIf", "bd" }, + [0x01E] = { "SetFlag", "w" }, + [0x01F] = { "ClearFlag", "w" }, + [0x020] = { "CheckFlag", "w" }, + [0x021] = { "SetFlagVar", "w" }, + [0x022] = { "ClearFlagVar", "w" }, + [0x023] = { "CheckFlagVar", "ww" }, + [0x024] = { "SetTrainerFlag", "w" }, + [0x025] = { "ClearTrainerFlag", "w" }, + [0x026] = { "CheckTrainerFlag", "w" }, + [0x027] = { "AddVar", "ww" }, + [0x028] = { "SubVar", "ww" }, + [0x029] = { "SetVar", "ww" }, + [0x02A] = { "CopyVar", "ww" }, + [0x02B] = { "SetOrCopyVar", "ww" }, + [0x02C] = { "NonNPCMsg", "b" }, + [0x02D] = { "NPCMsg", "b" }, + [0x02E] = { "NonNPCMsgVar", "w" }, + [0x02F] = { "NPCMsgVar", "w" }, + [0x030] = { "ScrCmd_048", "b" }, + [0x031] = { "WaitABPress", "" }, + [0x032] = { "WaitButton", "" }, + [0x033] = { "WaitButtonOrDpad", "" }, + [0x034] = { "OpenMsg", "" }, + [0x035] = { "CloseMsg", "" }, + [0x036] = { "HoldMsg", "" }, + [0x037] = { "DirectionSignpost", "bbww" }, + [0x038] = { "SetSignpostMap", "bw" }, + [0x039] = { "SetSignpostAction", "b" }, + [0x03A] = { "WaitSignpostAction", "" }, + [0x03B] = { "TrainerTips", "bw" }, + [0x03C] = { "WaitSignpost", "w" }, + [0x03D] = { "ScrCmd_061", "" }, + [0x03E] = { "ScrCmd_062", "bbbbbb" }, + [0x03F] = { "YesNo", "w" }, + [0x040] = { "ScrCmd_064", "bbbbw" }, + [0x041] = { "ScrCmd_065", "bbbbw" }, + [0x042] = { "ScrCmd_066", "bb" }, + [0x043] = { "ScrCmd_067", "" }, + [0x044] = { "ScrCmd_068", "bbbbw" }, + [0x045] = { "ScrCmd_069", "bbbbw" }, + [0x046] = { "ScrCmd_070", "www" }, + [0x047] = { "ScrCmd_071", "" }, + [0x048] = { "ScrCmd_072", "b" }, + [0x049] = { "PlaySE", "w" }, + [0x04A] = { "StopSE", "w" }, + [0x04B] = { "WaitSE", "w" }, + [0x04C] = { "PlayCry", "ww" }, + [0x04D] = { "WaitCry", "" }, + [0x04E] = { "PlayFanfare", "w" }, + [0x04F] = { "WaitFanfare", "" }, + [0x050] = { "PlayBGM", "w" }, + [0x051] = { "StopBGM", "w" }, + [0x052] = { "ResetBGM", "" }, + [0x053] = { "ScrCmd_083", "w" }, + [0x054] = { "FadeOutBGM", "ww" }, + [0x055] = { "FadeInBGM", "w" }, + [0x056] = { "ScrCmd_086", "bb" }, + [0x057] = { "TempBGM", "w" }, + [0x058] = { "ScrCmd_088", "b" }, + [0x059] = { "ChatotHasCry", "w" }, + [0x05A] = { "ChatotStartRecording", "w" }, + [0x05B] = { "ChatotStopRecording", "" }, + [0x05C] = { "ChatotSaveRecording", "" }, + [0x05D] = { "ScrCmd_093", "" }, + [0x05E] = { "ApplyMovement", "wd" }, + [0x05F] = { "WaitMovement", "" }, + [0x060] = { "LockAll", "" }, + [0x061] = { "ReleaseAll", "" }, + [0x062] = { "Lock", "w" }, + [0x063] = { "Release", "w" }, + [0x064] = { "ShowPerson", "w" }, + [0x065] = { "HidePerson", "w" }, + [0x066] = { "ScrCmd_102", "ww" }, + [0x067] = { "ScrCmd_103", "" }, + [0x068] = { "FacePlayer", "" }, + [0x069] = { "GetPlayerCoords", "ww" }, + [0x06A] = { "GetPersonCoords", "www" }, + [0x06B] = { "ScrCmd_107", "www" }, + [0x06C] = { "ScrCmd_108", "wb" }, + [0x06D] = { "ScrCmd_109", "ww" }, + [0x06E] = { "AddMoney", "d" }, + [0x06F] = { "SubMoneyImmediate", "d" }, + [0x070] = { "HasEnoughMoneyImmediate", "wd" }, + [0x071] = { "ShowMoneyBox", "ww" }, + [0x072] = { "HideMoneyBox", "" }, + [0x073] = { "UpdateMoneyBox", "" }, + [0x074] = { "ScrCmd_116", "bww" }, + [0x075] = { "ScrCmd_117", "" }, + [0x076] = { "ScrCmd_118", "b" }, + [0x077] = { "GetCoinAmount", "w" }, + [0x078] = { "GiveCoins", "w" }, + [0x079] = { "TakeCoins", "w" }, + [0x07A] = { "GiveAthletePoints", "w" }, + [0x07B] = { "TakeAthletePoints", "w" }, + [0x07C] = { "CheckAthletePoints", "ww" }, + [0x07D] = { "GiveItem", "www" }, + [0x07E] = { "TakeItem", "www" }, + [0x07F] = { "HasSpaceForItem", "www" }, + [0x080] = { "HasItem", "www" }, + [0x081] = { "ItemIsTMOrHM", "ww" }, + [0x082] = { "GetItemPocket", "ww" }, + [0x083] = { "SetStarterChoice", "w" }, + [0x084] = { "GenderMsgBox", "bb" }, + [0x085] = { "GetSealQuantity", "ww" }, + [0x086] = { "GiveOrTakeSeal", "ww" }, + [0x087] = { "GiveRandomSeal", "www" }, + [0x088] = { "ScrCmd_136", "ww" }, + [0x089] = { "GiveMon", "wwwwww" }, + [0x08A] = { "GiveEgg", "ww" }, + [0x08B] = { "SetMonMove", "www" }, + [0x08C] = { "MonHasMove", "www" }, + [0x08D] = { "GetPartySlotWithMove", "ww" }, + [0x08E] = { "GetPhoneBookRematch", "ww" }, + [0x08F] = { "NameRival", "w" }, + [0x090] = { "GetFriendSprite", "w" }, + [0x091] = { "RegisterPokegearCard", "b" }, + [0x092] = { "RegisterGearNumber", "w" }, + [0x093] = { "CheckRegisteredPhoneNumber", "ww" }, + [0x094] = { "ScrCmd_148", "bb" }, + [0x095] = { "UnsetPhoneCallTrigger", "b" }, + [0x096] = { "RestoreOverworld", "" }, + [0x097] = { "ScrCmd_151", "" }, + [0x098] = { "ScrCmd_152", "" }, + [0x099] = { "ScrCmd_153", "w" }, + [0x09A] = { "ScrCmd_154", "www" }, + [0x09B] = { "ScrCmd_155", "ww" }, + [0x09C] = { "ScrCmd_156", "" }, + [0x09D] = { "TownMap", "" }, + [0x09E] = { "ScrCmd_158", "b" }, + [0x09F] = { "ScrCmd_159", "" }, + [0x0A0] = { "ScrCmd_160", "" }, + [0x0A1] = { "ScrCmd_161", "" }, + [0x0A2] = { "ScrCmd_162", "" }, + [0x0A3] = { "HOFCredits", "w" }, + [0x0A4] = { "ScrCmd_164", "" }, + [0x0A5] = { "ScrCmd_165", "ww" }, + [0x0A6] = { "ScrCmd_166", "w" }, + [0x0A7] = { "ChooseStarter", "" }, + [0x0A8] = { "GetTrainerPathToPlayer", "w" }, + [0x0A9] = { "TrainerStepTowardsPlayer", "ww" }, + [0x0AA] = { "GetTrainerEyeType", "w" }, + [0x0AB] = { "GetEyeTrainerNum", "ww" }, + [0x0AC] = { "NamePlayer", "w" }, + [0x0AD] = { "NicknameInput", "ww" }, + [0x0AE] = { "FadeScreen", "wwww" }, + [0x0AF] = { "WaitFade", "" }, + [0x0B0] = { "Warp", "wwwww" }, + [0x0B1] = { "RockClimb", "w" }, + [0x0B2] = { "Surf", "w" }, + [0x0B3] = { "Waterfall", "w" }, + [0x0B4] = { "ScrCmd_180", "www" }, + [0x0B5] = { "FlashEffect", "" }, + [0x0B6] = { "Whirlpool", "w" }, + [0x0B7] = { "ScrCmd_183", "w" }, + [0x0B8] = { "PlayerOnBikeCheck", "w" }, + [0x0B9] = { "PlayerOnBikeSet", "b" }, + [0x0BA] = { "SetBikeStateLock", "b" }, + [0x0BB] = { "GetPlayerState", "w" }, + [0x0BC] = { "SetAvatarBits", "w" }, + [0x0BD] = { "UpdateAvatarState", "" }, + [0x0BE] = { "BufferPlayersName", "b" }, + [0x0BF] = { "BufferRivalsName", "b" }, + [0x0C0] = { "BufferFriendsName", "b" }, + [0x0C1] = { "BufferMonSpeciesName", "bw" }, + [0x0C2] = { "BufferItemName", "bw" }, + [0x0C3] = { "BufferPocketName", "bw" }, + [0x0C4] = { "BufferTMHMMoveName", "bw" }, + [0x0C5] = { "BufferMoveName", "bw" }, + [0x0C6] = { "BufferInt", "bw" }, + [0x0C7] = { "BufferPartyMonNick", "bw" }, + [0x0C8] = { "BufferTrainerClassName", "bw" }, + [0x0C9] = { "BufferPlayerUnionAvatarClassName", "b" }, + [0x0CA] = { "BufferSpeciesName", "bwwb" }, + [0x0CB] = { "BufferStarterSpeciesName", "b" }, + [0x0CC] = { "BufferDPPtRivalStarterSpeciesName", "b" }, + [0x0CD] = { "BufferDPPtFriendStarterSpeciesName", "b" }, + [0x0CE] = { "GetStarterChoice", "w" }, + [0x0CF] = { "BufferDecorationName", "bw" }, + [0x0D0] = { "ScrCmd_208", "bw" }, + [0x0D1] = { "ScrCmd_209", "bw" }, + [0x0D2] = { "BufferMapSecName", "bw" }, + [0x0D3] = { "ScrCmd_211", "ww" }, + [0x0D4] = { "GetTrainerNum", "w" }, + [0x0D5] = { "TrainerBattle", "wwbb" }, + [0x0D6] = { "TrainerMessage", "ww" }, + [0x0D7] = { "GetTrainerMsgParams", "www" }, + [0x0D8] = { "GetRematchMsgParams", "www" }, + [0x0D9] = { "TrainerIsDoubleBattle", "w" }, + [0x0DA] = { "EncounterMusic", "w" }, + [0x0DB] = { "WhiteOut", "" }, + [0x0DC] = { "CheckBattleWon", "w" }, + [0x0DD] = { "StaticWildWonOrCaughtCheck", "wb" }, + [0x0DE] = { "PartyCheckForDouble", "w" }, + [0x0DF] = { "ScrCmd_223", "" }, + [0x0E0] = { "ScrCmd_224", "" }, + [0x0E1] = { "GoToIfTrainerDefeated", "d" }, + [0x0E2] = { "ScrCmd_226", "wwww" }, + [0x0E3] = { "ScrCmd_227", "wwww" }, + [0x0E4] = { "ScrCmd_228", "w" }, + [0x0E5] = { "ScrCmd_229", "w" }, + [0x0E6] = { "ScrCmd_230", "" }, + [0x0E7] = { "ScrCmd_231", "" }, + [0x0E8] = { "ScrCmd_232", "w" }, + [0x0E9] = { "ScrCmd_233", "w" }, + [0x0EA] = { "ScrCmd_234", "wwww" }, + [0x0EB] = { "ScrCmd_235", "w" }, + [0x0EC] = { "ScrCmd_236", "w" }, + [0x0ED] = { "ScrCmd_237", "" }, + [0x0EE] = { "PartyHasPokerus", "w" }, + [0x0EF] = { "MonGetGender", "ww" }, + [0x0F0] = { "SetDynamicWarp", "wwwww" }, + [0x0F1] = { "GetDynamicWarpFloorNo", "w" }, + [0x0F2] = { "ElevatorCurFloorBox", "bbww" }, + [0x0F3] = { "CountJohtoDexSeen", "w" }, + [0x0F4] = { "CountJohtoDexOwned", "w" }, + [0x0F5] = { "CountNationalDexSeen", "w" }, + [0x0F6] = { "CountNationalDexOwned", "w" }, + [0x0F7] = { "ScrCmd_247", "" }, + [0x0F8] = { "GetDexEvalResult", "bww" }, + [0x0F9] = { "RocketTrapBattle", "ww" }, + [0x0FA] = { "ScrCmd_250", "ww" }, + [0x0FB] = { "CatchingTutorial", "" }, + [0x0FC] = { "ScrCmd_252", "" }, + [0x0FD] = { "GetSaveFileState", "w" }, + [0x0FE] = { "SaveGameNormal", "w" }, + [0x0FF] = { "ScrCmd_255", "ww" }, + [0x100] = { "ScrCmd_256", "w" }, + [0x101] = { "ScrCmd_257", "w" }, + [0x102] = { "ScrCmd_258", "" }, + [0x103] = { "ScrCmd_259", "w" }, + [0x104] = { "ScrCmd_260", "w" }, + [0x105] = { "ScrCmd_261", "w" }, + [0x106] = { "ScrCmd_262", "" }, + [0x107] = { "ScrCmd_263", "" }, + [0x108] = { "ScrCmd_264", "w" }, + [0x109] = { "ScrCmd_265", "" }, + [0x10A] = { "ScrCmd_266", "" }, + [0x10B] = { "ScrCmd_267", "ww" }, + [0x10C] = { "ScrCmd_268", "w" }, + [0x10D] = { "ScrCmd_269", "w" }, + [0x10E] = { "ScrCmd_270", "" }, + [0x10F] = { "ScrCmd_271", "ww" }, + [0x110] = { "ScrCmd_272", "w" }, + [0x111] = { "ScrCmd_273", "w" }, + [0x112] = { "ScrCmd_274", "ww" }, + [0x113] = { "MartBuy", "w" }, + [0x114] = { "SpecialMartBuy", "w" }, + [0x115] = { "DecorationMart", "w" }, + [0x116] = { "SealMart", "w" }, + [0x117] = { "OverworldWhiteOut", "" }, + [0x118] = { "SetSpawn", "w" }, + [0x119] = { "GetPlayerGender", "w" }, + [0x11A] = { "HealParty", "" }, + [0x11B] = { "ScrCmd_283", "" }, + [0x11C] = { "ScrCmd_284", "" }, + [0x11D] = { "ScrCmd_285", "w" }, + [0x11E] = { "ScrCmd_286", "" }, + [0x11F] = { "BufferUnionRoomAvatarChoices", "" }, + [0x120] = { "UnionRoomAvatarIdxToTrainerClass", "ww" }, + [0x121] = { "ScrCmd_289", "w" }, + [0x122] = { "CheckPokedex", "w" }, + [0x123] = { "GivePokedex", "" }, + [0x124] = { "CheckRunningShoes", "w" }, + [0x125] = { "GiveRunningShoes", "" }, + [0x126] = { "CheckBadge", "ww" }, + [0x127] = { "GiveBadge", "w" }, + [0x128] = { "CountBadges", "w" }, + [0x129] = { "ScrCmd_297", "w" }, + [0x12A] = { "ScrCmd_298", "" }, + [0x12B] = { "CheckEscortMode", "w" }, + [0x12C] = { "SetEscortMode", "" }, + [0x12D] = { "ClearEscortMode", "" }, + [0x12E] = { "CheckStepTakenFlag", "w" }, + [0x12F] = { "SetStepTakenFlag", "" }, + [0x130] = { "GetStepTakenFlag", "" }, + [0x131] = { "CheckGameClearFlag", "w" }, + [0x132] = { "SetGameClearFlag", "" }, + [0x133] = { "ScrCmd_307", "wwwwb" }, + [0x134] = { "ScrCmd_308", "b" }, + [0x135] = { "ScrCmd_309", "b" }, + [0x136] = { "ScrCmd_310", "b" }, + [0x137] = { "ScrCmd_311", "b" }, + [0x138] = { "BufferDaycareMonNicks", "" }, + [0x139] = { "GetDaycareState", "w" }, + [0x13A] = { "EcruteakGymInit", "" }, + [0x13B] = { "BindEcruteakGymCandleToTrainerSprite", "" }, + [0x13C] = { "UnbindEcruteakGymCandleFromTrainerSprite", "" }, + [0x13D] = { "ExtinguishEcruteakGymCandle", "b" }, + [0x13E] = { "CianwoodGymInit", "" }, + [0x13F] = { "CianwoodGymTurnWinch", "w" }, + [0x140] = { "VermilionGymInit", "" }, + [0x141] = { "VermilionGymLockAction", "bb" }, + [0x142] = { "VermilionGymCanCheck", "bw" }, + [0x143] = { "ResampleVermilionGymCans", "" }, + [0x144] = { "VioletGymInit", "" }, + [0x145] = { "VioletGymElevator", "" }, + [0x146] = { "AzaleaGymInit", "" }, + [0x147] = { "AzaleaGymSpinarak", "b" }, + [0x148] = { "AzaleaGymSwitch", "b" }, + [0x149] = { "BlackthornGymInit", "" }, + [0x14A] = { "FuchsiaGymInit", "" }, + [0x14B] = { "ViridianGymInit", "" }, + [0x14C] = { "GetPartyCount", "w" }, + [0x14D] = { "ScrCmd_333", "b" }, + [0x14E] = { "ScrCmd_334", "w" }, + [0x14F] = { "ScrCmd_335", "ww" }, + [0x150] = { "BufferBerryName", "bww" }, + [0x151] = { "BufferNatureName", "bw" }, + [0x152] = { "MovePerson", "www" }, + [0x153] = { "MovePersonFacing", "wwwww" }, + [0x154] = { "SetObjectMovementType", "ww" }, + [0x155] = { "SetObjectFacing", "ww" }, + [0x156] = { "MoveWarp", "www" }, + [0x157] = { "MoveBGEvent", "www" }, + [0x158] = { "ScrCmd_344", "ww" }, + [0x159] = { "AddWaitingIcon", "" }, + [0x15A] = { "RemoveWaitingIcon", "" }, + [0x15B] = { "ScrCmd_347", "w" }, + [0x15C] = { "WaitButtonOrDelay", "w" }, + [0x15D] = { "PartySelectUI", "" }, + [0x15E] = { "ScrCmd_350", "" }, + [0x15F] = { "GetPartySelection", "w" }, + [0x160] = { "PokemonSummaryScreen", "bww" }, + [0x161] = { "GetMoveSelection", "bw" }, + [0x162] = { "GetPartyMonSpecies", "ww" }, + [0x163] = { "PartyMonIsMine", "ww" }, + [0x164] = { "PartyCountNotEgg", "w" }, + [0x165] = { "CountAliveMons", "ww" }, + [0x166] = { "CountAliveMonsAndPC", "w" }, + [0x167] = { "PartyCountEgg", "w" }, + [0x168] = { "SubMoneyVar", "w" }, + [0x169] = { "RetrieveDaycareMon", "ww" }, + [0x16A] = { "GiveLoanMon", "bbw" }, + [0x16B] = { "CheckReturnLoanMon", "bww" }, + [0x16C] = { "ReturnLoanMon", "w" }, + [0x16D] = { "ResetDaycareEgg", "" }, + [0x16E] = { "GiveDaycareEgg", "" }, + [0x16F] = { "BufferDaycareWithdrawCost", "ww" }, + [0x170] = { "HasEnoughMoneyVar", "ww" }, + [0x171] = { "EggHatchAnim", "" }, + [0x172] = { "ScrCmd_370", "b" }, + [0x173] = { "BufferDaycareMonGrowth", "ww" }, + [0x174] = { "GetTailDaycareMonSpeciesAndNick", "w" }, + [0x175] = { "PutMonInDaycare", "w" }, + [0x176] = { "ScrCmd_374", "w" }, + [0x177] = { "MakeObjectVisible", "w" }, + [0x178] = { "ScrCmd_376", "" }, + [0x179] = { "ScrCmd_377", "w" }, + [0x17A] = { "ViewRankings", "ww" }, + [0x17B] = { "ScrCmd_379", "w" }, + [0x17C] = { "Random", "ww" }, + [0x17D] = { "ScrCmd_381", "ww" }, + [0x17E] = { "MonGetFriendship", "ww" }, + [0x17F] = { "MonAddFriendship", "ww" }, + [0x180] = { "MonSubtractFriendship", "ww" }, + [0x181] = { "BufferDaycareMonStats", "wwww" }, + [0x182] = { "GetPlayerFacing", "w" }, + [0x183] = { "GetDaycareCompatibility", "w" }, + [0x184] = { "CheckDaycareEgg", "w" }, + [0x185] = { "PlayerHasSpecies", "ww" }, + [0x186] = { "SizeRecordCompare", "ww" }, + [0x187] = { "SizeRecordUpdate", "w" }, + [0x188] = { "BufferMonSize", "www" }, + [0x189] = { "BufferRecordSize", "www" }, + [0x18A] = { "ScrCmd_394", "w" }, + [0x18B] = { "ScrCmd_395", "w" }, + [0x18C] = { "CountMonMoves", "ww" }, + [0x18D] = { "MonForgetMove", "ww" }, + [0x18E] = { "MonGetMove", "www" }, + [0x18F] = { "BufferPartyMonMoveName", "bww" }, + [0x190] = { "StrengthFlagAction", "*" }, + [0x191] = { "FlashAction", "*" }, + [0x192] = { "DefogAction", "*" }, + [0x193] = { "ScrCmd_403", "ww" }, + [0x194] = { "ScrCmd_404", "www" }, + [0x195] = { "ScrCmd_405", "www" }, + [0x196] = { "ScrCmd_406", "w" }, + [0x197] = { "ScrCmd_407", "ww" }, + [0x198] = { "ScrCmd_408", "ww" }, + [0x199] = { "ScrCmd_409", "" }, + [0x19A] = { "ScrCmd_410", "ww" }, + [0x19B] = { "ScrCmd_411", "" }, + [0x19C] = { "ScrCmd_412", "www" }, + [0x19D] = { "ScrCmd_413", "wwww" }, + [0x19E] = { "ScrCmd_414", "w" }, + [0x19F] = { "ScrCmd_415", "w" }, + [0x1A0] = { "ScrCmd_416", "www" }, + [0x1A1] = { "ScrCmd_417", "ww" }, + [0x1A2] = { "ScrCmd_418", "ww" }, + [0x1A3] = { "ScrCmd_419", "w" }, + [0x1A4] = { "ScrCmd_420", "w" }, + [0x1A5] = { "ScrCmd_421", "www" }, + [0x1A6] = { "ScrCmd_422", "wwwb" }, + [0x1A7] = { "CheckJohtoDexComplete", "w" }, + [0x1A8] = { "CheckNationalDexComplete", "w" }, + [0x1A9] = { "ShowCertificate", "w" }, + [0x1AA] = { "KenyaCheck", "wwb" }, + [0x1AB] = { "ScrCmd_427", "w" }, + [0x1AC] = { "MonGiveMail", "w" }, + [0x1AD] = { "CountFossils", "w" }, + [0x1AE] = { "SetPhoneCall", "www" }, + [0x1AF] = { "RunPhoneCall", "" }, + [0x1B0] = { "GetFossilPokemon", "ww" }, + [0x1B1] = { "GetFossilMinimumAmount", "www" }, + [0x1B2] = { "PartyCountMonsAtOrBelowLevel", "ww" }, + [0x1B3] = { "SurvivePoisoning", "ww" }, + [0x1B4] = { "ScrCmd_436", "" }, + [0x1B5] = { "DebugWatch", "w" }, + [0x1B6] = { "GetStdMsgNaix", "ww" }, + [0x1B7] = { "NonNPCMsgExtern", "ww" }, + [0x1B8] = { "MsgBoxExtern", "ww" }, + [0x1B9] = { "ScrCmd_441", "wwww" }, + [0x1BA] = { "ScrCmd_442", "wwww" }, + [0x1BB] = { "ScrCmd_443", "b" }, + [0x1BC] = { "ScrCmd_444", "bwwb" }, + [0x1BD] = { "ScrCmd_445", "w" }, + [0x1BE] = { "ScrCmd_446", "w" }, + [0x1BF] = { "SafariZoneAction", "bb" }, + [0x1C0] = { "ScrCmd_448", "wwwww" }, + [0x1C1] = { "ScrCmd_449", "" }, + [0x1C2] = { "ScrCmd_450", "" }, + [0x1C3] = { "ScrCmd_451", "w" }, + [0x1C4] = { "ScrCmd_452", "ww" }, + [0x1C5] = { "ScrCmd_453", "" }, + [0x1C6] = { "ScrCmd_454", "" }, + [0x1C7] = { "ScrCmd_455", "" }, + [0x1C8] = { "ScrCmd_456", "b" }, + [0x1C9] = { "MonGetNature", "ww" }, + [0x1CA] = { "GetPartySlotWithNature", "ww" }, + [0x1CB] = { "ScrCmd_459", "" }, + [0x1CC] = { "LoadPhoneDat", "ww" }, + [0x1CD] = { "GetPhoneContactMsgIds", "bww" }, + [0x1CE] = { "ScrCmd_462", "w" }, + [0x1CF] = { "EnableMassOutbreaks", "" }, + [0x1D0] = { "CreateRoamer", "b" }, + [0x1D1] = { "ScrCmd_465", "*" }, + [0x1D2] = { "ScrCmd_466", "ww" }, + [0x1D3] = { "MoveRelearnerInit", "w" }, + [0x1D4] = { "MoveTutorInit", "ww" }, + [0x1D5] = { "MoveRelearnerGetResult", "w" }, + [0x1D6] = { "LoadNPCTrade", "b" }, + [0x1D7] = { "GetOfferedSpecies", "w" }, + [0x1D8] = { "NPCTradeGetReqSpecies", "w" }, + [0x1D9] = { "NPCTradeExec", "w" }, + [0x1DA] = { "NPCTradeEnd", "" }, + [0x1DB] = { "ScrCmd_475", "" }, + [0x1DC] = { "EnablePokedexFormDetection", "" }, + [0x1DD] = { "NatDexFlagAction", "bw" }, + [0x1DE] = { "MonGetRibbonCount", "ww" }, + [0x1DF] = { "GetPartyRibbonCount", "w" }, + [0x1E0] = { "MonHasRibbon", "www" }, + [0x1E1] = { "GiveRibbon", "ww" }, + [0x1E2] = { "BufferRibbonName", "bw" }, + [0x1E3] = { "GetEVTotal", "ww" }, + [0x1E4] = { "GetWeekday", "w" }, + [0x1E5] = { "StartBattleRegulationMenuTask", "w" }, + [0x1E6] = { "Dummy486", "" }, + [0x1E7] = { "PokeCenAnim", "w" }, + [0x1E8] = { "ElevatorAnim", "ww" }, + [0x1E9] = { "MysteryGift", "*" }, + [0x1EA] = { "NopVar490", "w" }, + [0x1EB] = { "ScrCmd_491", "w" }, + [0x1EC] = { "ScrCmd_492", "www" }, + [0x1ED] = { "PromptEasyChat", "www" }, + [0x1EE] = { "ScrCmd_494", "ww" }, + [0x1EF] = { "GetGameVersion", "w" }, + [0x1F0] = { "GetPartyLead", "w" }, + [0x1F1] = { "GetMonTypes", "www" }, + [0x1F2] = { "PrimoPasswordCheck1", "wwwww" }, + [0x1F3] = { "PrimoPasswordCheck2", "wwwww" }, + [0x1F4] = { "ScrCmd_500", "b" }, + [0x1F5] = { "ScrCmd_501", "b" }, + [0x1F6] = { "ScrCmd_502", "b" }, + [0x1F7] = { "LotoIDGet", "w" }, + [0x1F8] = { "LotoIDSearch", "wwww" }, + [0x1F9] = { "LotoIDSet", "" }, + [0x1FA] = { "BufferBoxMonNick", "bw" }, + [0x1FB] = { "CountPCEmptySpace", "w" }, + [0x1FC] = { "PalParkAction", "w" }, + [0x1FD] = { "ScrCmd_509", "w" }, + [0x1FE] = { "ScrCmd_510", "" }, + [0x1FF] = { "PalParkScoreGet", "ww" }, + [0x200] = { "PlayerMovementSavingSet", "" }, + [0x201] = { "PlayerMovementSavingClear", "" }, + [0x202] = { "HallOfFameAnim", "w" }, + [0x203] = { "AddSpecialGameStat", "w" }, + [0x204] = { "BufferFashionName", "bw" }, + [0x205] = { "ScrCmd_517", "ww" }, + [0x206] = { "ScrCmd_518", "w" }, + [0x207] = { "ScrCmd_519", "w" }, + [0x208] = { "ScrCmd_520", "" }, + [0x209] = { "ScrCmd_521", "" }, + [0x20A] = { "ScrCmd_522", "w" }, + [0x20B] = { "ScrCmd_523", "wwwww" }, + [0x20C] = { "ScrCmd_524", "www" }, + [0x20D] = { "ScrCmd_525", "w" }, + [0x20E] = { "ScrCmd_526", "w" }, + [0x20F] = { "ScrCmd_527", "w" }, + [0x210] = { "ScrCmd_528", "w" }, + [0x211] = { "GetPartyLeadAlive", "w" }, + [0x212] = { "ScrCmd_530", "wb" }, + [0x213] = { "BufferBackgroundName", "bw" }, + [0x214] = { "CheckCoinsImmediate", "wd" }, + [0x215] = { "CheckGiveCoins", "ww" }, + [0x216] = { "ScrCmd_534", "w" }, + [0x217] = { "MonGetLevel", "ww" }, + [0x218] = { "ScrCmd_536", "ww" }, + [0x219] = { "ScrCmd_537", "" }, + [0x21A] = { "ScrCmd_538", "ww" }, + [0x21B] = { "ScrCmd_539", "w" }, + [0x21C] = { "ScrCmd_540", "w" }, + [0x21D] = { "BufferIntEx", "bwbb" }, + [0x21E] = { "MonGetContestValue", "www" }, + [0x21F] = { "ScrCmd_543", "w" }, + [0x220] = { "ScrCmd_544", "ww" }, + [0x221] = { "ScrCmd_545", "w" }, + [0x222] = { "ScrCmd_546", "bw" }, + [0x223] = { "ScrCmd_547", "w" }, + [0x224] = { "ScrCmd_548", "" }, + [0x225] = { "ScrCmd_549", "w" }, + [0x226] = { "ScrCmd_550", "w" }, + [0x227] = { "ScrCmd_551", "w" }, + [0x228] = { "ScrCmd_552", "ww" }, + [0x229] = { "ScrCmd_553", "bw" }, + [0x22A] = { "ScrCmd_554", "w" }, + [0x22B] = { "ScrCmd_555", "w" }, + [0x22C] = { "ScrCmd_556", "w" }, + [0x22D] = { "CheckBattlePoints", "ww" }, + [0x22E] = { "UnionRoomAvatarIdxToSprite", "ww" }, + [0x22F] = { "ScrCmd_559", "ww" }, + [0x230] = { "ScrCmd_560", "ww" }, + [0x231] = { "ScreenShake", "wwww" }, + [0x232] = { "MultiBattle", "wwwb" }, + [0x233] = { "ScrCmd_563", "www" }, + [0x234] = { "ScrCmd_564", "w" }, + [0x235] = { "ScrCmd_565", "w" }, + [0x236] = { "ScrCmd_566", "" }, + [0x237] = { "GetDPPlPrizeItemIDAndCost", "www" }, + [0x238] = { "ScrCmd_568", "ww" }, + [0x239] = { "ScrCmd_569", "w" }, + [0x23A] = { "CheckCoinsVar", "ww" }, + [0x23B] = { "ScrCmd_571", "wwwww" }, + [0x23C] = { "GetUniqueSealsQuantity", "w" }, + [0x23D] = { "ScrCmd_573", "" }, + [0x23E] = { "ScrCmd_574", "ww" }, + [0x23F] = { "ScrCmd_575", "ww" }, + [0x240] = { "ScrCmd_576", "w" }, + [0x241] = { "ScrCmd_577", "" }, + [0x242] = { "ScrCmd_578", "" }, + [0x243] = { "ScrCmd_579", "" }, + [0x244] = { "BufferSealName", "bw" }, + [0x245] = { "LockLastTalked", "" }, + [0x246] = { "ScrCmd_582", "www" }, + [0x247] = { "ScrCmd_583", "wb" }, + [0x248] = { "PartyLegalCheck", "w" }, + [0x249] = { "ScrCmd_585", "" }, + [0x24A] = { "ScrCmd_586", "w" }, + [0x24B] = { "ScrCmd_587", "" }, + [0x24C] = { "LatiCaughtCheck", "w" }, + [0x24D] = { "WildBattle", "wwb" }, + [0x24E] = { "GetTrcardStars", "w" }, + [0x24F] = { "ScrCmd_591", "" }, + [0x250] = { "ScrCmd_592", "w" }, + [0x251] = { "ShowSaveStats", "" }, + [0x252] = { "HideSaveStats", "" }, + [0x253] = { "ScrCmd_595", "b" }, + [0x254] = { "ScrCmd_596", "w" }, + [0x255] = { "ScrCmd_597", "" }, + [0x256] = { "ScrCmd_598", "w" }, + [0x257] = { "ScrCmd_599", "" }, + [0x258] = { "ScrCmd_600", "" }, + [0x259] = { "FollowMonFacePlayer", "" }, + [0x25A] = { "ToggleFollowingPokemonMovement", "w" }, + [0x25B] = { "WaitFollowingPokemonMovement", "" }, + [0x25C] = { "FollowingPokemonMovement", "w" }, + [0x25D] = { "ScrCmd_605", "bb" }, + [0x25E] = { "ScrCmd_606", "" }, + [0x25F] = { "ScrCmd_607", "" }, + [0x260] = { "ScrCmd_608", "" }, + [0x261] = { "ScrCmd_609", "" }, + [0x262] = { "ScrCmd_610", "w" }, + [0x263] = { "Pokeathlon", "bbwwwww" }, + [0x264] = { "GetNPCTradeUnusedFlag", "w" }, + [0x265] = { "GetPhoneContactRandomGiftBerry", "w" }, + [0x266] = { "GetPhoneContactGiftItem", "w" }, + [0x267] = { "CameronPhoto", "w" }, + [0x268] = { "CountSavedPhotos", "w" }, + [0x269] = { "OpenPhotoAlbum", "" }, + [0x26A] = { "PhotoAlbumIsFull", "w" }, + [0x26B] = { "RocketCostumeFlagCheck", "w" }, + [0x26C] = { "RocketCostumeFlagAction", "b" }, + [0x26D] = { "PlaceStarterBallsInElmsLab", "" }, + [0x26E] = { "ScrCmd_622", "ww" }, + [0x26F] = { "AnimApricornTree", "w" }, + [0x270] = { "ApricornTreeGetApricorn", "w" }, + [0x271] = { "GiveApricornFromTree", "www" }, + [0x272] = { "BufferApricornName", "bw" }, + [0x273] = { "ScrCmd_627", "b" }, + [0x274] = { "ScrCmd_628", "ww" }, + [0x275] = { "ScrCmd_629", "" }, + [0x276] = { "ScrCmd_630", "w" }, + [0x277] = { "ScrCmd_631", "www" }, + [0x278] = { "CountPartyMonsOfSpecies", "ww" }, + [0x279] = { "ScrCmd_633", "www" }, + [0x27A] = { "ScrCmd_634", "ww" }, + [0x27B] = { "ScrCmd_635", "ww" }, + [0x27C] = { "ScrCmd_636", "w" }, + [0x27D] = { "ScrCmd_637", "www" }, + [0x27E] = { "ScrCmd_638", "www" }, + [0x27F] = { "ScrCmd_639", "www" }, + [0x280] = { "ScrCmd_640", "w" }, + [0x281] = { "SaveWipeExtraChunks", "" }, + [0x282] = { "ScrCmd_642", "w" }, + [0x283] = { "ScrCmd_643", "www" }, + [0x284] = { "ScrCmd_644", "www" }, + [0x285] = { "ScrCmd_645", "www" }, + [0x286] = { "ScrCmd_646", "w" }, + [0x287] = { "GetPartySlotWithSpecies", "ww" }, + [0x288] = { "ScrCmd_648", "wwwww" }, + [0x289] = { "ScratchOffCard", "" }, + [0x28A] = { "ScratchOffCardEnd", "" }, + [0x28B] = { "GetScratchOffPrize", "www" }, + [0x28C] = { "ScrCmd_652", "www" }, + [0x28D] = { "MoveTutorChooseMove", "wwww" }, + [0x28E] = { "TutorMoveTeachInSlot", "www" }, + [0x28F] = { "TutorMoveGetPrice", "ww" }, + [0x290] = { "ScrCmd_656", "ww" }, + [0x291] = { "StatJudge", "wwww" }, + [0x292] = { "BufferStatName", "bw" }, + [0x293] = { "SetMonForm", "ww" }, + [0x294] = { "BufferTrainerName", "bw" }, + [0x295] = { "ScrCmd_661", "bdbb" }, + [0x296] = { "ScrCmd_662", "www" }, + [0x297] = { "ScrCmd_663", "w" }, + [0x298] = { "ScrCmd_664", "" }, + [0x299] = { "ScrCmd_665", "w" }, + [0x29A] = { "ScrCmd_666", "w" }, + [0x29B] = { "ScrCmd_667", "w" }, + [0x29C] = { "BufferTypeName", "bw" }, + [0x29D] = { "GetItemQuantity", "ww" }, + [0x29E] = { "GetHiddenPowerType", "ww" }, + [0x29F] = { "SetFavoriteMon", "" }, + [0x2A0] = { "GetFavoriteMon", "www" }, + [0x2A1] = { "GetOwnedRotomForms", "wwwww" }, + [0x2A2] = { "CountTranformedRotomsInParty", "ww" }, + [0x2A3] = { "UpdateRotomForm", "wwww" }, + [0x2A4] = { "GetPartyMonForm", "ww" }, + [0x2A5] = { "ScrCmd_677", "ww" }, + [0x2A6] = { "ScrCmd_678", "ww" }, + [0x2A7] = { "ScrCmd_679", "" }, + [0x2A8] = { "AddSpecialGameStat2", "w" }, + [0x2A9] = { "ScrCmd_681", "w" }, + [0x2AA] = { "ScrCmd_682", "w" }, + [0x2AB] = { "GetStaticEncounterOutcome", "w" }, + [0x2AC] = { "ScrCmd_684", "w" }, + [0x2AD] = { "GetPlayerXYZ", "www" }, + [0x2AE] = { "ScrCmd_686", "ww" }, + [0x2AF] = { "ScrCmd_687", "w" }, + [0x2B0] = { "GetPartySlotWithFatefulEncounter", "ww" }, + [0x2B1] = { "CommSanitizeParty", "w" }, + [0x2B2] = { "DaycareSanitizeMon", "ww" }, + [0x2B3] = { "ScrCmd_691", "w" }, + [0x2B4] = { "BufferBattleHallStreak", "bbbbww" }, + [0x2B5] = { "BattleHallCountUsedSpecies", "w" }, + [0x2B6] = { "BattleHallGetTotalStreak", "w" }, + [0x2B7] = { "ScrCmd_695", "w" }, + [0x2B8] = { "ScrCmd_696", "w" }, + [0x2B9] = { "ScrCmd_697", "w" }, + [0x2BA] = { "FollowerPokeIsEventTrigger", "bww" }, + [0x2BB] = { "ScrCmd_699", "" }, + [0x2BC] = { "ScrCmd_700", "" }, + [0x2BD] = { "MonHasItem", "ww" }, + [0x2BE] = { "BattleTowerSetUpMultiBattle", "" }, + [0x2BF] = { "SetPlayerVolume", "w" }, + [0x2C0] = { "ScrCmd_704", "ww" }, + [0x2C1] = { "ScrCmd_705", "wd" }, + [0x2C2] = { "ScrCmd_706", "w" }, + [0x2C3] = { "CheckMonSeen", "ww" }, + [0x2C4] = { "ScrCmd_708", "w" }, + [0x2C5] = { "ScrCmd_709", "" }, + [0x2C6] = { "ScrCmd_710", "" }, + [0x2C7] = { "FollowMonInteract", "" }, + [0x2C8] = { "ScrCmd_712", "b" }, + [0x2C9] = { "AlphPuzzle", "b" }, + [0x2CA] = { "OpenAlphHiddenRoom", "b" }, + [0x2CB] = { "UpdateDaycareMonObjects", "" }, + [0x2CC] = { "ScrCmd_716", "" }, + [0x2CD] = { "ScrCmd_717", "w" }, + [0x2CE] = { "ScrCmd_718", "bw" }, + [0x2CF] = { "ScrCmd_719", "ww" }, + [0x2D0] = { "ScrCmd_720", "w" }, + [0x2D1] = { "ScrCmd_721", "w" }, + [0x2D2] = { "ScrCmd_722", "bbwww" }, + [0x2D3] = { "ScrCmd_723", "bbwww" }, + [0x2D4] = { "ScrCmd_724", "ww" }, + [0x2D5] = { "ScrCmd_725", "bw" }, + [0x2D6] = { "ProcessSoundplate", "" }, + [0x2D7] = { "GetFollowPokePartyIndex", "w" }, + [0x2D8] = { "ScrCmd_728", "bb" }, + [0x2D9] = { "ScrCmd_729", "w" }, + [0x2DA] = { "ScrCmd_730", "w" }, + [0x2DB] = { "ScrCmd_731", "" }, + [0x2DC] = { "ScrCmd_732", "b" }, + [0x2DD] = { "ScrCmd_733", "bw" }, + [0x2DE] = { "ScrCmd_734", "b" }, + [0x2DF] = { "ScrCmd_735", "w" }, + [0x2E0] = { "ClearKurtApricorn", "" }, + [0x2E1] = { "ScrCmd_737", "w" }, + [0x2E2] = { "GetTotalApricornCount", "w" }, + [0x2E3] = { "ScrCmd_739", "" }, + [0x2E4] = { "ScrCmd_740", "ww" }, + [0x2E5] = { "ScrCmd_741", "wwww" }, + [0x2E6] = { "ScrCmd_742", "www" }, + [0x2E7] = { "ScrCmd_743", "w" }, + [0x2E8] = { "CreatePokeathlonFriendshipRoomStatues", "" }, + [0x2E9] = { "BufferPokeathlonCourseName", "bw" }, + [0x2EA] = { "TouchscreenMenuHide", "" }, + [0x2EB] = { "TouchscreenMenuShow", "" }, + [0x2EC] = { "GetMenuChoice", "w" }, + [0x2ED] = { "MenuInitStdGmm", "bbbbw" }, + [0x2EE] = { "MenuInit", "bbbbw" }, + [0x2EF] = { "MenuItemAdd", "www" }, + [0x2F0] = { "MenuExec", "" }, + [0x2F1] = { "RockSmashItemCheck", "www" }, + [0x2F2] = { "TryHeadbuttEncounter", "w" }, + [0x2F3] = { "LegendCutsceneClearBellAnimBegin", "" }, + [0x2F4] = { "LegendCutsceneClearBellAnimEnd", "" }, + [0x2F5] = { "LegendCutsceneClearBellRiseFromBag", "" }, + [0x2F6] = { "LegendCutsceneClearBellShimmer", "w" }, + [0x2F7] = { "LegendCutsceneLugiaEyeGlimmerEffect", "" }, + [0x2F8] = { "ScrCmd_760", "" }, + [0x2F9] = { "LegendCutsceneMoveCameraTo", "w" }, + [0x2FA] = { "LegendCutscenePanCameraTo", "w" }, + [0x2FB] = { "LegendCutsceneWaitCameraPan", "" }, + [0x2FC] = { "LegendCutsceneBirdFinalApproach", "" }, + [0x2FD] = { "LegendCutsceneWavesOrLeavesEffectBegin", "" }, + [0x2FE] = { "LegendCutsceneWavesOrLeavesEffectEnd", "" }, + [0x2FF] = { "LegendCutsceneLugiaArrivesEffectBegin", "" }, + [0x300] = { "LegendCutsceneLugiaArrivesEffectEnd", "" }, + [0x301] = { "LegendCutsceneLugiaArrivesEffectCameraPan", "" }, + [0x302] = { "CheckSeenAllLetterUnown", "w" }, + [0x303] = { "ScrCmd_771", "" }, + [0x304] = { "ScrCmd_772", "" }, + [0x305] = { "Cinematic", "w" }, + [0x306] = { "ShowLegendaryWing", "w" }, + [0x307] = { "ScrCmd_775", "ww" }, + [0x308] = { "GiveTogepiEgg", "" }, + [0x309] = { "ScrCmd_777", "ww" }, + [0x30A] = { "GiveSpikyEarPichu", "" }, + [0x30B] = { "RadioMusicIsPlaying", "ww" }, + [0x30C] = { "CasinoGame", "bb" }, + [0x30D] = { "KenyaCheckPartyOrMailbox", "w" }, + [0x30E] = { "MartSell", "" }, + [0x30F] = { "SetFollowMonInhibitState", "b" }, + [0x310] = { "ScriptOverlayCmd", "bb" }, + [0x311] = { "BugContestAction", "bw" }, + [0x312] = { "BufferBugContestWinner", "b" }, + [0x313] = { "JudgeBugContest", "www" }, + [0x314] = { "BufferBugContestMonNick", "bw" }, + [0x315] = { "BugContestGetTimeLeft", "b" }, + [0x316] = { "IsBugContestantRegistered", "ww" }, + [0x317] = { "CheckSafariZoneChallengeCompleted", "bw" }, + [0x318] = { "UpdateSafariZoneIGT", "" }, + [0x319] = { "BankTransaction", "ww" }, + [0x31A] = { "CheckBankBalance", "wd" }, + [0x31B] = { "ScrCmd_795", "ww" }, + [0x31C] = { "ScrCmd_796", "" }, + [0x31D] = { "ScrCmd_797", "" }, + [0x31E] = { "BufferRulesetName", "w" }, + [0x31F] = { "ScrCmd_799", "w" }, + [0x320] = { "ScrCmd_800", "w" }, + [0x321] = { "ScrCmd_801", "w" }, + [0x322] = { "ScrCmd_802", "" }, + [0x323] = { "ScrCmd_803", "ww" }, + [0x324] = { "ScrCmd_804", "b" }, + [0x325] = { "ScrCmd_805", "" }, + [0x326] = { "ScrCmd_806", "" }, + [0x327] = { "SetTrainerHouseSprite", "ww" }, + [0x328] = { "ScrCmd_808", "w" }, + [0x329] = { "ShowTrainerHouseIntroMessage", "w" }, + [0x32A] = { "ScrCmd_810", "" }, + [0x32B] = { "ScrCmd_811", "ww" }, + [0x32C] = { "ScrCmd_812", "" }, + [0x32D] = { "MomGiftCheck", "w" }, + [0x32E] = { "ScrCmd_814", "" }, + [0x32F] = { "ScrCmd_815", "w" }, + [0x330] = { "UnownCircle", "" }, + [0x331] = { "ScrCmd_817", "b" }, + [0x332] = { "MystriStageGymmickInit", "" }, + [0x333] = { "ScrCmd_819", "" }, + [0x334] = { "ScrCmd_820", "b" }, + [0x335] = { "GetBuenasPassword", "ww" }, + [0x336] = { "ScrCmd_822", "" }, + [0x337] = { "ScrCmd_823", "w" }, + [0x338] = { "ScrCmd_824", "w" }, + [0x339] = { "GetShinyLeafCount", "ww" }, + [0x33A] = { "TryGiveShinyLeafCrown", "w" }, + [0x33B] = { "GetPartyMonForm2", "ww" }, + [0x33C] = { "MonAddContestValue", "wbw" }, + [0x33D] = { "ScrCmd_829", "w" }, + [0x33E] = { "ScrCmd_830", "w" }, + [0x33F] = { "ScrCmd_831", "w" }, + [0x340] = { "ScrCmd_832", "w" }, + [0x341] = { "ScrCmd_833", "w" }, + [0x342] = { "ScrCmd_834", "w" }, + [0x343] = { "ScrCmd_835", "w" }, + [0x344] = { "CheckKyogreGroudonInParty", "w" }, + [0x345] = { "ScrCmd_837", "w" }, + [0x346] = { "BankOrWalletIsFull", "ww" }, + [0x347] = { "SysSetSleepFlag", "w" }, + [0x348] = { "ScrCmd_840", "ww" }, + [0x349] = { "ScrCmd_841", "b" }, + [0x34A] = { "ScrCmd_842", "b" }, + [0x34B] = { "BufferItemNameIndef", "bw" }, + [0x34C] = { "BufferItemNamePlural", "bw" }, + [0x34D] = { "BufferPartyMonSpeciesNameIndef", "bw" }, + [0x34E] = { "BufferSpeciesNameIndef", "bwwb" }, + [0x34F] = { "BufferDPPtFriendStarterSpeciesNameIndef", "b" }, + [0x350] = { "BufferFashionNameIndef", "bw" }, + [0x351] = { "BufferTrainerClassNameIndef", "bw" }, + [0x352] = { "BufferSealNamePlural", "bw" }, + [0x353] = { "Capitalize", "b" }, + [0x354] = { "BufferDeptStoreFloorNo", "bb" }, + }, + -- commands whose last operand is a jump (`dest-.-4` in the macro); + -- ApplyMovement's points at movement data rather than code + relative = { [0x016] = true, [0x017] = true, [0x018] = true, [0x019] = true, [0x01A] = true, [0x01C] = true, [0x01D] = true, [0x05E] = true, [0x0E1] = true }, +} diff --git a/src/import/Gen4MapHeaders.lua b/src/import/Gen4MapHeaders.lua index 768702c98..8455f5532 100644 --- a/src/import/Gen4MapHeaders.lua +++ b/src/import/Gen4MapHeaders.lua @@ -116,12 +116,74 @@ function Gen4MapHeaders.parse(record) } end +-- HEARTGOLD AND SOULSILVER: the same 24 bytes, laid out differently +-- (pokeheartgold include/map_header.h, 540 of them in src/data/map_headers.h): +-- +-- 00 u8 wildEncounterBank (0xFF none) 01 u8 areaDataBank +-- 02 u16 moveModelBank:4 worldMapX:6 worldMapY:6 +-- 04 u16 matrix 06 scripts 08 scriptHeader 0A msg +-- 0C u16 dayMusic 0E nightMusic 10 u16 events +-- 12 u16 mapsec:8 areaIcon:4 momCallIntro:4 +-- 14 u32 region:1 weather:7 mapType:4 cameraType:6 followMode:2 battleBg:5 +-- bike:1 running:1 escapeRope:1 fly:1 outgoingCalls:1 incomingCalls:1 +-- radioSignal:1 +-- +-- Parsed into Platinum's field names, so everything downstream reads one +-- shape: `scriptHeader` is what Platinum calls the init scripts, and `mapsec` +-- indexes the place-name bank as Platinum's `labelText` does. +Gen4MapHeaders.HGSS_NO_ENCOUNTERS = 0xFF +Gen4MapHeaders.HGSS_COUNT = 540 + +function Gen4MapHeaders.parseHgss(record) + if type(record) ~= "string" or #record < Gen4MapHeaders.RECORD_BYTES then + return nil, "map header record is too short" + end + local wild = u8(record, 0) + local mm = u16(record, 2) + local sec = u16(record, 0x12) + local bits = u16(record, 0x14) + u16(record, 0x16) * 65536 + local function field(shift, width) return floor(bits / 2 ^ shift) % 2 ^ width end + return { + family = "hgss", + encounters = (wild ~= Gen4MapHeaders.HGSS_NO_ENCOUNTERS) and wild or nil, + areaData = u8(record, 1), + moveModels = mm % 16, worldMapX = floor(mm / 16) % 64, worldMapY = floor(mm / 1024) % 64, + matrix = u16(record, 4), + scripts = u16(record, 6), + initScripts = u16(record, 8), + messages = u16(record, 0x0A), + dayMusic = u16(record, 0x0C), + nightMusic = u16(record, 0x0E), + events = u16(record, 0x10), + labelText = sec % 256, mapsec = sec % 256, + areaIcon = floor(sec / 256) % 16, momCallIntro = floor(sec / 4096) % 16, + region = field(0, 1), + weather = field(1, 7), + mapType = field(8, 4), + camera = field(12, 6), + followMode = field(18, 2), + battleBackground = field(20, 5), + allowBike = field(25, 1) == 1, + allowRunning = field(26, 1) == 1, + allowEscapeRope = field(27, 1) == 1, + allowFly = field(28, 1) == 1, + outgoingCalls = field(29, 1) == 1, + incomingCalls = field(30, 1) == 1, + radioSignal = field(31, 1) == 1, + } +end + +-- the parser for a family ("hgss" or Platinum's) +function Gen4MapHeaders.parserFor(family) + return family == "hgss" and Gen4MapHeaders.parseHgss or Gen4MapHeaders.parse +end + -- `limits` names how many members each referenced archive actually has, and -- is what makes the search trustworthy. Every field is required, because the -- signature is only as strong as its weakest constraint. -- { areaData, matrix, scripts, messages, encounters, events, names } local function looksLikeHeader(arm9, at, limits) - local h = Gen4MapHeaders.parse(arm9:sub(at + 1, at + Gen4MapHeaders.RECORD_BYTES)) + local h = (limits.parse or Gen4MapHeaders.parse)(arm9:sub(at + 1, at + Gen4MapHeaders.RECORD_BYTES)) if not h then return false end return h.areaData < limits.areaData and h.matrix < limits.matrix @@ -148,7 +210,7 @@ function Gen4MapHeaders.find(arm9, limits) end -- Try the known offset first, so the usual case costs one check. - local hinted = runAt(Gen4MapHeaders.KNOWN_OFFSET) + local hinted = limits.parse and 0 or runAt(Gen4MapHeaders.KNOWN_OFFSET) if hinted >= 500 then return Gen4MapHeaders.KNOWN_OFFSET, hinted end -- Otherwise scan. Word-aligned: the table is a compiled array and the @@ -169,15 +231,26 @@ function Gen4MapHeaders.find(arm9, limits) if bestN < 100 then return nil, ("no map header table found (longest plausible run was %d)"):format(bestN) end + -- THE SKIP ABOVE CAN STEP OVER THE FIRST RECORD: a run that begins a few + -- bytes early ends mid-record and the scan resumes one record late. Found on + -- HeartGold, whose MAP_EVERYWHERE came out missing and every map one id off, + -- so walk back while the record before still passes. + while bestAt >= Gen4MapHeaders.RECORD_BYTES + and looksLikeHeader(arm9, bestAt - Gen4MapHeaders.RECORD_BYTES, limits) do + bestAt, bestN = bestAt - Gen4MapHeaders.RECORD_BYTES, bestN + 1 + end + -- ...and never past the cartridge's own count of maps when it is known + if limits.count and bestN > limits.count then bestN = limits.count end return bestAt, bestN end -- All the headers, given the ARM9 and the offset/count `find` returned. -function Gen4MapHeaders.all(arm9, at, count) +function Gen4MapHeaders.all(arm9, at, count, parse) + parse = parse or Gen4MapHeaders.parse local out = {} for i = 0, count - 1 do local o = at + i * Gen4MapHeaders.RECORD_BYTES - local h = Gen4MapHeaders.parse(arm9:sub(o + 1, o + Gen4MapHeaders.RECORD_BYTES)) + local h = parse(arm9:sub(o + 1, o + Gen4MapHeaders.RECORD_BYTES)) if h then h.id = i; out[i] = h end end return out diff --git a/src/import/Gen4Maps.lua b/src/import/Gen4Maps.lua index 022aae856..6dacee234 100644 --- a/src/import/Gen4Maps.lua +++ b/src/import/Gen4Maps.lua @@ -221,12 +221,24 @@ function Gen4Maps.land(data) local permSize, objSize = u32(data, 0), u32(data, 4) local modelSize, bdhcSize = u32(data, 8), u32(data, 12) if not bdhcSize then return nil, "land chunk header is truncated" end - if 16 + permSize + objSize + modelSize + bdhcSize ~= #data then - return nil, ("land chunk sizes (%d+%d+%d+%d+16) do not add up to %d bytes") - :format(permSize, objSize, modelSize, bdhcSize, #data) - end local at = 16 local out = {} + -- HEARTGOLD AND SOULSILVER ADD A SECTION: after the four sizes, u16 0x1234 + -- and a u16 size, then that many bytes of background-sound data (the + -- ambient sounds a chunk plays), and only then the permissions. Recognised + -- by the magic AND by the sizes adding up with it, never by the version. + if 16 + permSize + objSize + modelSize + bdhcSize ~= #data and #data >= 20 + and u16(data, 16) == 0x1234 then + local bgsSize = u16(data, 18) + if 20 + bgsSize + permSize + objSize + modelSize + bdhcSize == #data then + out.backgroundSounds = data:sub(21, 20 + bgsSize) + at = 20 + bgsSize + end + end + if at + permSize + objSize + modelSize + bdhcSize ~= #data then + return nil, ("land chunk sizes (%d+%d+%d+%d+%d) do not add up to %d bytes") + :format(permSize, objSize, modelSize, bdhcSize, at, #data) + end out.permissions = data:sub(at + 1, at + permSize); at = at + permSize out.objectData = data:sub(at + 1, at + objSize); at = at + objSize out.model = data:sub(at + 1, at + modelSize); at = at + modelSize diff --git a/src/import/Gen4ModelPack.lua b/src/import/Gen4ModelPack.lua index fe44dbfc6..e09a7b980 100644 --- a/src/import/Gen4ModelPack.lua +++ b/src/import/Gen4ModelPack.lua @@ -201,12 +201,56 @@ function Gen4ModelPack.pack(model) -- to. A shadow polygon is 9. alpha = (material and material.alpha and material.alpha < 31) and material.alpha or nil, + -- HOW THE TEXTURE MEETS ITS EDGES, from the material's texture + -- parameter: bits 16 / 17 repeat S / T, 18 / 19 mirror them, and + -- neither is CLAMP. Only when it is not plain repeat, which is what the + -- renderer assumes. HGSS's starter balls show why it matters: their + -- highlight, `tt_bl_hl2`, is one small spot with parameter 0 -- clamped + -- -- and repeated it tiled the whole ball in white dots. + wrap = Gen4ModelPack.wrapOf(material), + -- NO NORMALS, NO LIGHTING: the DS computes a lit colour only from the + -- NORMAL command, and a shape whose display list never issued one + -- (HeartGold / SoulSilver's lightmapped `_lm` geometry, which issues + -- COLOR) shows its baked vertex colours as they are. Shaded with the + -- decoder's resting normal, Falkner's gym came out at a third of its + -- brightness. Platinum's terrain issues NORMAL and is unaffected. + unlit = Gen4ModelPack.hasNoNormals(shape) or nil, + -- OUT OF THE FOG: polyAttr bit 15 is the DS's per-material fog enable. + -- Written only when it is CLEAR, which is rare -- Morty's gym fogs the + -- whole room to black and its candle spots (`dun_spot`) shine through. + noFog = (material and material.polyAttr + and math.floor(material.polyAttr / 0x8000) % 2 == 0) or nil, } end return out end +-- hasNoNormals(shape) -> true when no vertex of it was given a NORMAL +function Gen4ModelPack.hasNoNormals(shape) + local vs = shape and shape.vertices + if type(vs) ~= "table" or #vs == 0 then return false end + for _, v in ipairs(vs) do + if not v.noNormal then return false end + end + return true +end + +-- wrapOf(material) -> { s, t } in LOVE's words, or nil for repeat / repeat +function Gen4ModelPack.wrapOf(material) + local p = material and material.texImageParam + if not p then return nil end + local function axis(repeatBit, mirrorBit) + local rep = math.floor(p / 2 ^ repeatBit) % 2 == 1 + local mir = math.floor(p / 2 ^ mirrorBit) % 2 == 1 + if not rep then return "clamp" end + return mir and "mirroredrepeat" or "repeat" + end + local ws, wt = axis(16, 18), axis(17, 19) + if ws == "repeat" and wt == "repeat" then return nil end + return { ws, wt } +end + -- unpack(packed) -> vertices, triangles -- -- The inverse, and it exists to be RUN rather than to be used: the extractor diff --git a/src/import/Gen4Models.lua b/src/import/Gen4Models.lua index 937ebe8e0..33bdea973 100644 --- a/src/import/Gen4Models.lua +++ b/src/import/Gen4Models.lua @@ -392,6 +392,18 @@ function Gen4Models.sheet(data, opts) opts = opts or {} local parsed = Gen4Models.parse(data) if not parsed then return nil end + -- `opts.color0Transparent`: the field draws these with colour 0 clear + -- whatever the texture's own bit 29 says (Black / White's character + -- billboards: Cheren's sheet leaves the bit off and came out on a solid + -- green square). Palette formats only -- 2, 3 and 4 are the ones with an + -- index 0 to clear. + if opts.color0Transparent then + for _, texture in ipairs(parsed.textures) do + if texture.format == 2 or texture.format == 3 or texture.format == 4 then + texture.transparent0 = true + end + end + end -- Which size the most decodable textures agree on. local tally, best, bestCount = {}, nil, 0 diff --git a/src/import/Gen4MoveTutor.lua b/src/import/Gen4MoveTutor.lua index deba3e19a..9e065bb3b 100644 --- a/src/import/Gen4MoveTutor.lua +++ b/src/import/Gen4MoveTutor.lua @@ -54,7 +54,48 @@ function Gen4MoveTutor.parse(ov) return { moves = moves, masks = masks } end +-- HEARTGOLD / SOULSILVER (pokeheartgold src/field/scrcmd_move_tutor.c): +-- sTutorMoves[52], 4 bytes each (u16 move, u8 BP cost, u8 tutor NPC -- 0..2 +-- the Frontier's three, 3 the Ilex Forest Headbutt man), compiled into an +-- overlay and found by its first two rows (Dive 40 / 0, Mud-Slap 32 / 1); +-- and fielddata/wazaoshie/waza_oshie.bin, 8 bytes (64 bits) per moveset, +-- row species - 1, the forms after the species exactly as Platinum's. The +-- output has Platinum's shape, the NPC standing in for `location`. +Gen4MoveTutor.HGSS = { COUNT = 52, STRIDE = 4, MASK_BYTES = 8, + MASKS = "/fielddata/wazaoshie/waza_oshie.bin", + SIGNATURE = string.char(0x23, 0x01, 40, 0, 0xBD, 0x00, 32, 1), + HEADBUTT_NPC = 3 } +function Gen4MoveTutor.parseHgss(ov, maskFile) + local H = Gen4MoveTutor.HGSS + local at = type(ov) == "string" and ov:find(H.SIGNATURE, 1, true) + if not at then return nil end + local moves = {} + for i = 0, H.COUNT - 1 do + local o = at + i * H.STRIDE + moves[i + 1] = { move = ov:byte(o) + ov:byte(o + 1) * 256, cost = ov:byte(o + 2), + location = ov:byte(o + 3) } + end + local masks = {} + for m = 1, math.floor(#maskFile / H.MASK_BYTES) do + local o = (m - 1) * H.MASK_BYTES + local hex = {} + for k = 1, H.MASK_BYTES do hex[k] = ("%02x"):format(maskFile:byte(o + k)) end + masks[m] = table.concat(hex) + end + return { moves = moves, masks = masks } +end + function Gen4MoveTutor.extract(rom) + if require("src.import.Gen4ScriptBands").family == "hgss" then + local maskFile = rom and rom:read(Gen4MoveTutor.HGSS.MASKS) + if not maskFile then return nil, "no waza_oshie.bin" end + for i = 0, 130 do + local ok, ov = pcall(rom.overlay, rom, i) + local out = ok and ov and Gen4MoveTutor.parseHgss(ov, maskFile) + if out then return out end + end + return nil, "sTutorMoves not found in any overlay" + end local ov = rom and rom:overlay(Gen4MoveTutor.OVERLAY) return Gen4MoveTutor.parse(ov) end diff --git a/src/import/Gen4Nsbmd.lua b/src/import/Gen4Nsbmd.lua index 0ef28a19a..4562e0ec1 100644 --- a/src/import/Gen4Nsbmd.lua +++ b/src/import/Gen4Nsbmd.lua @@ -163,6 +163,12 @@ function Gen4Nsbmd.decodeDisplayList(data, at, size) -- it does not mention alone. So all of it persists across commands. local x, y, z = 0, 0, 0 local nx, ny, nz = 0, 0, 1 + -- WHETHER A NORMAL WAS EVER GIVEN. The DS lights a vertex from the NORMAL + -- command; a display list that issues COLOR instead -- HeartGold / + -- SoulSilver's lightmapped (`_lm`) geometry -- carries its lighting baked + -- in, and (0, 0, 1) above is only this decoder's resting value, which read + -- as a real normal made every such floor a wall facing the camera. + local normalSet = false local u, v = 0, 0 local r, g, b = 1, 1, 1 local matrix = 0 @@ -172,6 +178,7 @@ function Gen4Nsbmd.decodeDisplayList(data, at, size) vertices[#vertices + 1] = { x = x, y = y, z = z, nx = nx, ny = ny, nz = nz, u = u, v = v, r = r, g = g, b = b, matrix = matrix, + noNormal = (not normalSet) or nil, } stats.vertices = stats.vertices + 1 return #vertices @@ -206,6 +213,7 @@ function Gen4Nsbmd.decodeDisplayList(data, at, size) g = bits(p1, 5, 5) / 31 b = bits(p1, 10, 5) / 31 elseif cmd == 0x21 then + normalSet = true nx = sN(bits(p1, 0, 10), 10) / 512 ny = sN(bits(p1, 10, 10), 10) / 512 nz = sN(bits(p1, 20, 10), 10) / 512 @@ -542,7 +550,36 @@ function Gen4Nsbmd.parse(data) -- the hardware register holds, and the renderer is the right place to -- decide what 9/31 looks like in this engine's blend. alpha = math.floor(polyAttr / 2 ^ 16) % 32, + -- THE TEXTURE PARAMETER, +0x14: its bits 30..31 are the texture + -- coordinate SOURCE -- 0 none, 1 the vertex's own, 2 the NORMAL + -- (environment mapping: the shine on HGSS's starter balls), 3 the + -- vertex position. + texImageParam = u32(data, record + 0x14) or 0, + texGen = math.floor((u32(data, record + 0x14) or 0) / 2 ^ 30) % 4, + -- THE TEXTURE MATRIX (NNSG3dResMatData): `flag` at +0x1E -- bit 0 use + -- it, bit 1 scale is one, bit 2 rotation is zero, bit 3 translation + -- is zero -- and after the two fx32 magnifications at +0x24 / +0x28, + -- an fx32 scale pair when bit 1 is clear. HGSS's floors repeat their + -- small plank and stone textures this way; read without it, Falkner's + -- gym stretched one 32-pixel plank across whole walkways. + texFlag = u16(data, record + 0x1E) or 0, + -- diffuse | ambient << 16 and specular | emission << 16, RGB555 each, + -- bit 15 of diffuse saying "vertex colour is the diffuse" + diffAmb = u32(data, record + 0x04) or 0, + specEmi = u32(data, record + 0x08) or 0, } + do + local m = model.materials[#model.materials] + local flag = m.texFlag + if flag % 2 == 1 and math.floor(flag / 2) % 2 == 0 then + local function fx32(at) + local v = u32(data, at) or 0 + if v >= 0x80000000 then v = v - 0x100000000 end + return v / 4096 + end + m.texScaleS, m.texScaleT = fx32(record + 0x2C), fx32(record + 0x30) + end + end end -- ...and the same fact the way a renderer wants it: material -> names. for _, tex in ipairs(model.textures) do diff --git a/src/import/Gen4Particle.lua b/src/import/Gen4Particle.lua index b7381ca12..c8924078f 100644 --- a/src/import/Gen4Particle.lua +++ b/src/import/Gen4Particle.lua @@ -138,7 +138,7 @@ function Gen4Particle.shape(param) return { format = param % 8, width = w, height = h } end --- textures(data) -> { { format, width, height, pixels, palette }, ... } +-- textures(data) -> { { format, width, height, pixels, palette, repeatS/T, flipS/T }, ... } -- -- `palette` is a flat list of { r, g, b } 0-255, and it is TRIMMED: the -- cartridge stores only the colours a texture uses, so most are two entries @@ -187,6 +187,13 @@ function Gen4Particle.textures(data) pixels = sub(data, at + Gen4Particle.TEXTURE_HEADER_BYTES + 1, at + Gen4Particle.TEXTURE_HEADER_BYTES + texBytes), palette = palette, + -- SPLTextureParam bits 12-15: repeat S/T, then FLIP S/T. A flipped + -- repeat mirrors every other copy, which is how a quarter-circle tiled + -- 2x2 becomes a ring (spl_draw.c hands the bits to the polygon). + repeatS = floor(param / 4096) % 2 == 1, + repeatT = floor(param / 8192) % 2 == 1, + flipS = floor(param / 16384) % 2 == 1, + flipT = floor(param / 32768) % 2 == 1, } at = at + total n = n + 1 diff --git a/src/import/Gen4PoketchArt.lua b/src/import/Gen4PoketchArt.lua index a881fb345..054262921 100644 --- a/src/import/Gen4PoketchArt.lua +++ b/src/import/Gen4PoketchArt.lua @@ -236,20 +236,48 @@ function Gen4PoketchArt.images(rom) local pal = G.palette(member("generic_bg_tiles.NCLR")) if not pal then return nil, "poketch palette unreadable" end local out = {} - for set, loads in pairs(Gen4PoketchArt.SETS) do + -- OBJ palette rows apps load over generic_bg_tiles.NCLR: the dowsing + -- machine's rows 13 and 14 are the theme's row with colour 1 replaced by 8 + -- and by 15 (dowsing_machine/graphics.c Task_DrawBackground). + local function rowsWith(over) + local p = {} + for i, c in ipairs(pal) do p[i] = c end + for row, src in pairs(over) do + for k = 0, 15 do p[row * 16 + k + 1] = pal[k + 1] end + for k, from in pairs(src) do p[row * 16 + k + 1] = pal[from + 1] end + end + return p + end + local palFor = { dowsing_machine = rowsWith({ [13] = { [1] = 8 }, [14] = { [1] = 15 } }) } + local function cells(set, loads, key, colours) local sheet = vram(member, G, loads) local ncer = member(set .. "_cell.NCER.lz") local bank = sheet and Gen4PoketchArt.centreDoubleSize(ncer, Cells.parse(ncer, G), G) if bank then for i, cell in ipairs(bank.cells) do - local pic = Cells.assemble(cell, sheet, pal, bank, G) + local pic = Cells.assemble(cell, sheet, colours or palFor[set] or pal, bank, G) if pic then pic.originX, pic.originY = Cells.extent(cell) - out[("%s_%02d"):format(set, i - 1)] = pic + out[("%s_%02d"):format(key or set, i - 1)] = pic end end end end + for set, loads in pairs(Gen4PoketchArt.SETS) do cells(set, loads) end + -- the app number on the shutter (PoketchGraphics_LoadAppCounterPalette): + -- the digits in the theme's row with colours 1/4 and 8/15 swapped + local inverse = {} + for i, c in ipairs(pal) do inverse[i] = c end + inverse[2], inverse[5], inverse[9], inverse[16] = pal[5], pal[2], pal[16], pal[9] + cells("digits", Gen4PoketchArt.SETS.digits, "digits_inverse", inverse) + -- the border's own tiles (the shutter is its tile 164) in its palette + local border, borderPal = G.tiles(member("poketch_border.NCGR.lz")), G.palette(member("poketch_border.NCLR")) + if border and borderPal then + local rows = math.ceil(border.count / 32) + local list = {} + for i = 0, rows * 32 - 1 do list[i + 1] = { tile = i < border.count and i or 0, palette = 0 } end + out.border_bgtiles = G.compose({ width = 256, height = rows * 8, cells = list }, border, borderPal) + end -- Tilemaps composed over the BG sheet the app loads under them. for key, pair in pairs(Gen4PoketchArt.COMPOSE) do local sheet, map = G.tiles(member(pair[2])), G.tilemap(member(pair[1])) diff --git a/src/import/Gen4Script.lua b/src/import/Gen4Script.lua index 86316f067..4d12e3de4 100644 --- a/src/import/Gen4Script.lua +++ b/src/import/Gen4Script.lua @@ -114,8 +114,13 @@ end -- Decode one script from `at` (1-based). Returns the instruction list and a -- reason the walk stopped: "end", "variable", "unknown" or "overrun". -function Gen4Script.decode(data, at) +-- +-- `ops` is the command table, Platinum's by default; HeartGold and SoulSilver +-- pass Gen4HgssScriptOps, whose opcodes are different numbers entirely. +function Gen4Script.decode(data, at, ops) if type(data) ~= "string" or type(at) ~= "number" then return nil, "bad arguments" end + local Ops = ops or Ops + local JUMPS = Ops.JUMPS or Gen4Script.JUMPS local out, pc = {}, at local stopped = "overrun" while pc + 1 <= #data do @@ -146,7 +151,7 @@ function Gen4Script.decode(data, at) -- A jump's operand is the LAST one, signed, and measured from the byte -- after it -- which is pc + size, since the operand ends the instruction. - local jump = Gen4Script.JUMPS[op] + local jump = JUMPS[op] if jump then local rel = i32(data, pc + size - 4) if rel then instruction.target = pc + size + rel end @@ -160,10 +165,10 @@ function Gen4Script.decode(data, at) end -- Every script in one member, keyed by entry-point position. -function Gen4Script.decodeAll(data) +function Gen4Script.decodeAll(data, ops) local out = {} for _, at in ipairs(Gen4Script.entries(data)) do - local instructions, stopped = Gen4Script.decode(data, at) + local instructions, stopped = Gen4Script.decode(data, at, ops) out[#out + 1] = { at = at, instructions = instructions, stopped = stopped } end return out diff --git a/src/import/Gen4ScriptBands.lua b/src/import/Gen4ScriptBands.lua index 7addc3014..50a06d0e9 100644 --- a/src/import/Gen4ScriptBands.lua +++ b/src/import/Gen4ScriptBands.lua @@ -167,4 +167,82 @@ function Gen4ScriptBands.classify(id) return "map", nil, id - 1, nil end +-- --------------------------------------------------------------------------- +-- HEARTGOLD AND SOULSILVER +-- --------------------------------------------------------------------------- +-- +-- The same dispatcher with its own table: pokeheartgold's sScriptBankMapping +-- (src/script_manager.c), thresholds from include/constants/std_script.h. The +-- scr_seq member and the text bank are NUMBERS there (scr_seq_0263_bin, +-- msg_0433), so the member is carried directly rather than looked up by name. +-- An id below every band and above 0 is the map's own; 0 is scr_seq 140 with +-- bank 184. The battle bands are Platinum's thresholds, 3000 and 5000, so +-- `trainerId` answers for both. +Gen4ScriptBands.HGSS = { + BANDS = { + { 10490, "scratch_card", 263, 433 }, + { 10450, "frontier_records", 264, 19 }, + { 10440, "frontier_move_tutor", 2, 748 }, + { 10400, "bug_contest", 151, 246 }, + { 10350, "trainer_house", 952, 726 }, + { 10300, "std_10300", 734, 444 }, + { 10200, "mystery_gift", 144, 209 }, + { 10150, "std_10150", 955, 732 }, + { 10100, "tv", 954, 733 }, + { 10000, "field_move", 146, 211 }, + { 9950, "dex_evaluation", 148, 666 }, + { 9900, "std_9900", 136, 40 }, + { 9850, "pokeathlon", 167, 312 }, + { 9800, "std_9800", 166, 43 }, + { 9700, "following_mon", 163, 266 }, + { 9600, "init", 149, 40 }, + { 9500, "daycare", 265, 439 }, + { 9300, "group", 143, 204 }, + { 9200, "wifi_reception", 164, 267 }, + { 9100, "colosseum", 0, 14 }, + { 9000, "comm_reception", 4, 46 }, + { 8900, "chatot", 165, 268 }, + { 8800, "safari", 262, 427 }, + { 8000, "hidden_items", 145, 210 }, + { 7000, "visible_items", 141, 199 }, + { 5000, "double_battles", 953, 40 }, + { 3000, "single_battles", 953, 40 }, + { 2800, "apricorn_tree", 150, 23 }, + { 2500, "bookshelves", 1, 20 }, + { 2000, "common_scripts", 3, 40 }, + }, + -- ov01_021EDAFC opens msg_0191 when a menu is given no bank + MENU_ENTRIES_BANK = 191, + DUMMY_MEMBER = 140, DUMMY_TEXT_BANK = 184, +} + +-- PLATINUM'S TABLES, kept so `setFamily` can put them back +Gen4ScriptBands.PLATINUM = { + BANDS = Gen4ScriptBands.BANDS, TEXT_BANK = Gen4ScriptBands.TEXT_BANK, + MENU_ENTRIES_BANK = Gen4ScriptBands.MENU_ENTRIES_BANK, +} + +-- setFamily("hgss" | anything else): which cartridge's dispatcher `classify` +-- and the menu bank answer for. HGSS's BANDS rows carry the member number as +-- their third field and the text bank as their fourth; TEXT_BANK is +-- rebuilt by name so every existing reader keeps working. +-- +-- "bw" takes its rows from the cartridge (Gen5ScriptLink.findBands, written +-- to gen5_version.scriptBands) in the same { first, name, member, bank } shape. +function Gen4ScriptBands.setFamily(family, rows) + local src = family == "hgss" and Gen4ScriptBands.HGSS + or family == "bw" and { BANDS = rows or {}, MENU_ENTRIES_BANK = Gen4ScriptBands.PLATINUM.MENU_ENTRIES_BANK } + or Gen4ScriptBands.PLATINUM + Gen4ScriptBands.family = (family == "hgss" or family == "bw") and family or "platinum" + Gen4ScriptBands.BANDS = src.BANDS + Gen4ScriptBands.MENU_ENTRIES_BANK = src.MENU_ENTRIES_BANK + if family == "hgss" or family == "bw" then + local tb = {} + for _, band in ipairs(src.BANDS) do tb[band[2]] = band[4] end + Gen4ScriptBands.TEXT_BANK = tb + else + Gen4ScriptBands.TEXT_BANK = src.TEXT_BANK + end +end + return Gen4ScriptBands diff --git a/src/import/Gen4Sdat.lua b/src/import/Gen4Sdat.lua index 9b945ef61..1319ea7ce 100644 --- a/src/import/Gen4Sdat.lua +++ b/src/import/Gen4Sdat.lua @@ -219,8 +219,25 @@ function Gen4Sdat.sequence(self, index) local off = u32(self.data, base + 4 + index * 4) if not off or off == 0 then return nil end local at = self.info + off + -- SEQ INFO record: u16 file, u16 pad, u16 bank, u8 volume, u8 channel + -- priority, u8 player priority, u8 player, u16 reserved. return { file = u16(self.data, at), bank = u16(self.data, at + 4), - volume = u8(self.data, at + 6), index = index } + volume = u8(self.data, at + 6), channelPriority = u8(self.data, at + 7), + playerPriority = u8(self.data, at + 8), player = u8(self.data, at + 9), + index = index } +end +-- PLAYER INFO record (INFO kind 4): u8 max sequences, u8 pad, u16 channel +-- mask, u32 heap. An SE player with room for one sequence is why a second +-- menu blip cuts the first one off on the cartridge. +Gen4Sdat.PLAYER = 4 +function Gen4Sdat.player(self, index) + local base, count = records(self, self.info, Gen4Sdat.PLAYER) + if not base or index < 0 or index >= count then return nil end + local off = u32(self.data, base + 4 + index * 4) + if not off or off == 0 then return nil end + local at = self.info + off + return { maxSequences = u8(self.data, at), channels = u16(self.data, at + 2), + index = index, name = Gen4Sdat.name(self, Gen4Sdat.PLAYER, index) } end function Gen4Sdat.instrument(self, bankIndex, program, note) local bank = Gen4Sdat.bank(self, bankIndex) diff --git a/src/import/Gen4StartMenuArt.lua b/src/import/Gen4StartMenuArt.lua new file mode 100644 index 000000000..ef0f0fa09 --- /dev/null +++ b/src/import/Gen4StartMenuArt.lua @@ -0,0 +1,73 @@ +-- PLATINUM'S FIELD START MENU ICONS (src/start_menu.c StartMenu_InitSprites), +-- from /graphic/menu_gra.narc: icons.NCGR, icons_cell.NCER, icons_anim.NANR +-- under menu.NCLR. +-- +-- Each option's icon is animation option * 3 + {0 still, 1 swell, 2 wiggle} +-- (a female player's BAG is option 9's), and its palette is SET, not added: +-- Sprite_SetExplicitPaletteWithOffset(PALETTE_GRAYSCALE = 0 or PALETTE_COLORED +-- = 1) -- so every cell is assembled twice, with menu.NCLR row 0 and row 1, +-- whatever its OAM entries say. +-- +-- Pictures: icon__ (32x32, origin -16,-16). Data +-- (`gen4_start_menu_ink`): anims[seq + 1] = { mode, frames = { { cell, +-- duration, rot, sx, sy, x, y } } } -- the swell is an SRT scale 1 -> 1.3 -> 1, +-- the wiggle a +-10 degree rotation. + +local Gen4StartMenuArt = {} + +Gen4StartMenuArt.PATH = "/graphic/menu_gra.narc" + +local function opener(rom) + local G = require("src.import.Gen4Graphics") + local A = require("src.import.Gen4Archives") + local bytes = rom:read(Gen4StartMenuArt.PATH) + if not bytes then return nil end + local narc = require("src.import.NarcArchive").parse(bytes) + return function(name) + local i = A.find(Gen4StartMenuArt.PATH, name) + local b = i and narc:get(i) + if b and G.isCompressed(b) then b = G.decompress(b) end + return b + end +end + +function Gen4StartMenuArt.images(rom) + local G = require("src.import.Gen4Graphics") + local Cells = require("src.import.Gen4Cells") + local member = opener(rom) + if not member then return nil, "menu_gra missing" end + local pal = G.palette(member("menu.NCLR")) + local sheet = G.tiles(member("icons.NCGR")) + local bank = Cells.parse(member("icons_cell.NCER"), G) + if not (pal and sheet and bank) then return nil, "menu_gra icons unreadable" end + local out = {} + for row = 0, 1 do + local colours = {} + for i = 1, 16 do colours[i] = pal[row * 16 + i] or { 0, 0, 0 } end + for i, cell in ipairs(bank.cells) do + local flat = { oam = {} } + for k, v in pairs(cell) do if k ~= "oam" then flat[k] = v end end + for j, o in ipairs(cell.oam) do + local c = {} + for k, v in pairs(o) do c[k] = v end + c.palette = 0 + flat.oam[j] = c + end + local pic = Cells.assemble(flat, sheet, colours, bank, G) + if pic then + pic.originX, pic.originY = Cells.extent(flat) + out[("icon_%02d_%d"):format(i - 1, row)] = pic + end + end + end + return out +end + +function Gen4StartMenuArt.data(rom) + local G = require("src.import.Gen4Graphics") + local member = opener(rom) + if not member then return nil end + return { anims = require("src.import.Gen4PoketchArt").anims(member("icons_anim.NANR"), G) } +end + +return Gen4StartMenuArt diff --git a/src/import/Gen4Terrain.lua b/src/import/Gen4Terrain.lua index b3cb8c817..10f560005 100644 --- a/src/import/Gen4Terrain.lua +++ b/src/import/Gen4Terrain.lua @@ -197,6 +197,10 @@ function Gen4Terrain.append(state, chunk) -- take effect: a cache written before today carries no alpha, which is -- what the name fallback over there is for. alpha = shape.alpha, + -- unlit by the cartridge (Gen4ModelPack): no area-light tint + unlit = shape.unlit, + -- out of the map's fog (Gen4ModelPack): polyAttr bit 15 clear + noFog = shape.noFog, } end diff --git a/src/import/Gen4TownMapArt.lua b/src/import/Gen4TownMapArt.lua new file mode 100644 index 000000000..39b4d249a --- /dev/null +++ b/src/import/Gen4TownMapArt.lua @@ -0,0 +1,239 @@ +-- PLATINUM'S TOWN MAP, THE BOTTOM SCREEN'S PICTURES (pokeplatinum +-- src/applications/town_map/main.c, graphics.c), from /graphic/tmap_gra.narc +-- (member names in src/import/Gen4Archives.lua) and /graphic/field_board.narc: +-- +-- zoom_screen SUB_1 bottom_screen_zoom_button_screen (28) over +-- bottom_screen_button_tiles (21), the whole 256 x 192 the +-- zoom-button page shows (DrawZoomButton(.., TRUE)) +-- zoom_button_N zoom_button_states (27): the 6 x 7 tiles at (6 N, 0) laid +-- at tile (13, 10) -- N 0 up, 1 / 2 the press frames +-- header zoom_button_states (0, 7) 12 x 2, laid at tile (10, 0) on +-- the zoomed page (PrintBottomScreenHeader) +-- zoom_bg SUB_3 bottom_screen_region_bg_tilemap (25), 512 x 512 +-- zoom_map SUB_2 bottom_screen_region_map_tilemap (26), 512 x 512, +-- both over bottom_screen_map_tiles (20); all BG under +-- bottom_screen_bg.NCLR (1) at sub BG palette 0 +-- hidden hidden_location_bottom_screen (29), the pieces +-- ShowHiddenLocation copies onto those two +-- shockwave_N zoom_button_shockwave (4/5/6) cells over its NCLR (3): +-- sequence 0 = cells 0, 1, 2 four frames each +-- sign_frame_T field_board signpost_frame (0), its 18 frame tiles in a +-- row, under signpost.NCLR (1) row T (LoadSignpostContent- +-- Graphics loads row signpostType into BG palette 14) +-- sign_T_M the 6 x 4 signpost picture: city_map_empty + M for a MAP +-- signpost (T 0), route_map_00 + M for an ARROW one (T 1) +-- +-- `data(rom)` (gen4_town_map_ink): the full tmap_block records (with the +-- description offsets the top-screen importer leaves out), tmap_flags.dat's +-- description checks, and the text colours: palette 14's (1, 2) per +-- signpost type and the sub font palette's. +-- +-- Written to assets/generated/gen4/town_map_art/.png, indexed in +-- `gen4_town_map_art`. + +local Gen4TownMapArt = {} + +Gen4TownMapArt.PATH = "/graphic/tmap_gra.narc" +Gen4TownMapArt.BOARD = "/graphic/field_board.narc" +Gen4TownMapArt.BLOCKS = "/data/tmap_block.dat" +Gen4TownMapArt.FLAGS = "/data/tmap_flags.dat" +Gen4TownMapArt.CITY_MAP_EMPTY = 33 +Gen4TownMapArt.ROUTE_MAP_00 = 2 + +local floor = math.floor + +local function opener(rom, path) + local G = require("src.import.Gen4Graphics") + local bytes = rom:read(path) + if not bytes then return nil end + local narc = require("src.import.NarcArchive").parse(bytes) + if not narc then return nil end + return function(i) + local b = narc:get(i) + if b and G.isCompressed(b) then b = G.decompress(b) end + return b + end +end + +local function member(path, name, fallback) + local A = require("src.import.Gen4Archives") + return A.find(path, name) or fallback +end + +local function u16(b, at) local x, y = b:byte(at + 1, at + 2) return (x or 0) + (y or 0) * 256 end + +-- every tmap_block record, all twelve fields +function Gen4TownMapArt.parseBlocks(b) + if type(b) ~= "string" or #b < 4 then return nil end + local n = u16(b, 0) + local out = {} + for i = 0, n - 1 do + local o = 4 + i * 24 + if o + 24 > #b then break end + out[#out + 1] = { + x = u16(b, o), z = u16(b, o + 2), signpostType = u16(b, o + 4), signpost = u16(b, o + 6), + areaDesc = u16(b, o + 8), landmarkDesc = u16(b, o + 10), + areaX = u16(b, o + 12), areaY = u16(b, o + 14), landmarkX = u16(b, o + 16), landmarkY = u16(b, o + 18), + hidden = u16(b, o + 20), index = i, + } + end + return out +end + +-- tmap_flags.dat: an int count, then { areaType, areaFlag, landmarkType, +-- landmarkFlag } bytes per block (TOWN_MAP_DESC_FLAG_NONE / FIRST_ARRIVAL / +-- GENERAL) +function Gen4TownMapArt.parseFlags(b) + if type(b) ~= "string" or #b < 4 then return nil end + local n = u16(b, 0) + local out = {} + for i = 0, n - 1 do + local o = 4 + i * 4 + if o + 4 > #b then break end + local a, af, l, lf = b:byte(o + 1, o + 4) + out[i + 1] = { areaType = a, areaFlag = af, landmarkType = l, landmarkFlag = lf } + end + return out +end + +function Gen4TownMapArt.images(rom) + local G = require("src.import.Gen4Graphics") + local Cells = require("src.import.Gen4Cells") + local P = Gen4TownMapArt.PATH + local m = opener(rom, P) + if not m then return nil, "tmap_gra.narc missing" end + local out = {} + local pal = G.palette(m(member(P, "bottom_screen_bg.NCLR", 1))) + local mapTiles = G.tiles(m(member(P, "bottom_screen_map_tiles.NCGR", 20))) + local btnTiles = G.tiles(m(member(P, "bottom_screen_button_tiles.NCGR", 21))) + if not (pal and mapTiles and btnTiles) then return nil, "tmap_gra bottom screen unreadable" end + local zoomScreen = G.tilemap(m(member(P, "bottom_screen_zoom_button_screen.NSCR", 28))) + local states = G.tilemap(m(member(P, "zoom_button_states.NSCR", 27))) + if zoomScreen then out.zoom_screen = G.compose(zoomScreen, btnTiles, pal) end + if states then + local cols = floor(states.width / 8) + local function rect(sx, sy, w, h) + local cells = {} + for y = 0, h - 1 do + for x = 0, w - 1 do + cells[#cells + 1] = states.cells[(sy + y) * cols + sx + x + 1] or { tile = 0, palette = 0 } + end + end + return G.compose({ width = w * 8, height = h * 8, cells = cells }, btnTiles, pal) + end + for n = 0, 2 do out["zoom_button_" .. n] = rect(n * 6, 0, 6, 7) end + out.header = rect(0, 7, 12, 2) + end + for key, name in pairs({ zoom_bg = "bottom_screen_region_bg_tilemap.NSCR", zoom_map = "bottom_screen_region_map_tilemap.NSCR", + hidden = "hidden_location_bottom_screen.NSCR" }) do + local map = G.tilemap(m(member(P, name, ({ zoom_bg = 25, zoom_map = 26, hidden = 29 })[key]))) + if map then out[key] = G.compose(map, mapTiles, pal) end + end + -- the zoom button's shockwave + local sh = G.tiles(m(member(P, "zoom_button_shockwave.NCGR", 4))) + local bank = Cells.parse(m(member(P, "zoom_button_shockwave_cell.NCER", 5)), G) + local spal = G.palette(m(member(P, "zoom_button_shockwave.NCLR", 3))) + if sh and bank and spal then + for i, cell in ipairs(bank.cells) do + local use = { oam = {} } + for k, v in pairs(cell) do if k ~= "oam" then use[k] = v end end + for j, o in ipairs(cell.oam) do + local c = {} + for k, v in pairs(o) do c[k] = v end + c.palette = 0 + use.oam[j] = c + end + local pic = Cells.assemble(use, sh, spal, bank, G) + if pic then pic.originX, pic.originY = Cells.extent(use) end + out["shockwave_" .. (i - 1)] = pic + end + end + -- the signposts + local B = Gen4TownMapArt.BOARD + local fb = opener(rom, B) + if fb then + local frame = G.tiles(fb(member(B, "signpost_frame.NCGR", 0))) + local bpal = G.palette(fb(member(B, "signpost.NCLR", 1))) + if frame and bpal then + local function row(r) + local c = {} + for i = 1, 16 do c[i] = bpal[r * 16 + i] or { 0, 0, 0 } end + return c + end + for t = 0, 3 do + local cells = {} + for i = 0, 17 do cells[i + 1] = { tile = i, palette = 0 } end + out["sign_frame_" .. t] = G.compose({ width = 18 * 8, height = 8, cells = cells }, frame, row(t)) + end + local seen = {} + for _, blk in ipairs(Gen4TownMapArt.parseBlocks(rom:read(Gen4TownMapArt.BLOCKS)) or {}) do + local t = blk.signpostType + if (t == 0 or t == 1) and not seen[t .. "_" .. blk.signpost] then + seen[t .. "_" .. blk.signpost] = true + local idx = blk.signpost + (t == 0 and Gen4TownMapArt.CITY_MAP_EMPTY or Gen4TownMapArt.ROUTE_MAP_00) + local tiles = G.tiles(fb(idx)) + if tiles then + local cells = {} + for i = 0, 23 do cells[i + 1] = { tile = i, palette = 0 } end + out[("sign_%d_%d"):format(t, blk.signpost)] = G.compose({ width = 48, height = 32, cells = cells }, tiles, row(t)) + end + end + end + end + end + for k, v in pairs(out) do if not v then out[k] = nil end end + return out +end + +function Gen4TownMapArt.data(rom) + local G = require("src.import.Gen4Graphics") + local out = { + blocks = Gen4TownMapArt.parseBlocks(rom:read(Gen4TownMapArt.BLOCKS)), + descFlags = Gen4TownMapArt.parseFlags(rom:read(Gen4TownMapArt.FLAGS)), + signInk = {}, + } + local fb = opener(rom, Gen4TownMapArt.BOARD) + local bpal = fb and G.palette(fb(member(Gen4TownMapArt.BOARD, "signpost.NCLR", 1))) + if bpal then + for t = 0, 3 do + local function c(i) local x = bpal[t * 16 + i + 1] return x and { x[1], x[2], x[3] } end + out.signInk[t] = { text = c(1), shadow = c(2), paper = c(15) } + end + end + local P = Gen4TownMapArt.PATH + local m = opener(rom, P) + local pal = m and G.palette(m(member(P, "bottom_screen_bg.NCLR", 1))) + if pal and pal[1] then out.backdrop = { pal[1][1], pal[1][2], pal[1][3] } end + -- GetTouchedZone on the zoom-button page: a tap anywhere is the button + -- unless SUB_1's pixel there is colour 1 or 4 (Bg_DoesPixelAtXYMatchVal, + -- mask 0x0012). Kept as runs { y, x0, x1 } of the pixels that DO count. + local tiles = m and G.tiles(m(member(P, "bottom_screen_button_tiles.NCGR", 21))) + local screen = m and G.tilemap(m(member(P, "bottom_screen_zoom_button_screen.NSCR", 28))) + if tiles and screen then + -- an identity palette: red = 16 * palette row + colour index + local id = {} + for i = 0, 255 do id[i + 1] = { i, 0, 0 } end + local pic = G.compose(screen, tiles, id) + if pic then + local runs = {} + for y = 0, math.min(pic.height, 192) - 1 do + local start + for x = 0, 256 do + local hit = false + if x < 256 and x < pic.width then + local at = (y * pic.width + x) * 4 + local r, a = pic.rgba:byte(at + 1), pic.rgba:byte(at + 4) + local c = (a ~= 0) and (r % 16) or 0 + hit = c ~= 1 and c ~= 4 + end + if hit and not start then start = x end + if not hit and start then runs[#runs + 1] = { y, start, x - 1 }; start = nil end + end + end + out.zoomHit = runs + end + end + return out +end + +return Gen4TownMapArt diff --git a/src/import/Gen4Trades.lua b/src/import/Gen4Trades.lua index 8a2386cc7..9c27350d7 100644 --- a/src/import/Gen4Trades.lua +++ b/src/import/Gen4Trades.lua @@ -110,9 +110,13 @@ function Gen4Trades.otGender(value) end -- Every trade, as rows the runtime reads. `names` is the decoded bank. -function Gen4Trades.all(narc, names) +-- `count` is the cartridge's trade count, Platinum's 4 by default; the +-- name bank holds the nicknames first and the OT names after them, so the +-- count is also where the OT names start (HGSS: 13, NPC_TRADE_OT_NUM). +function Gen4Trades.all(narc, names, count) + count = count or Gen4Trades.COUNT local out = {} - for id = 0, Gen4Trades.COUNT - 1 do + for id = 0, count - 1 do local parsed = narc and Gen4Trades.parse(narc:get(id)) if parsed then local ivs = {} @@ -122,7 +126,7 @@ function Gen4Trades.all(narc, names) species = parsed.species, request = parsed.requestedSpecies, nickname = names and names[Gen4Trades.nicknameEntry(id) + 1] or nil, - otName = names and names[Gen4Trades.otNameEntry(id) + 1] or nil, + otName = names and names[count + id + 1] or nil, otId = parsed.otID, otGender = Gen4Trades.otGender(parsed.otGender), item = parsed.heldItem, diff --git a/src/import/Gen4Trgra.lua b/src/import/Gen4Trgra.lua index 77818b382..c01e46f93 100644 --- a/src/import/Gen4Trgra.lua +++ b/src/import/Gen4Trgra.lua @@ -86,14 +86,18 @@ end -- sheet(narc, Gen4Graphics, class) -> { pixels, width, height, bpp } -- `pixels` is DECRYPTED and linear, one nibble per pixel, low nibble first. -function Gen4Trgra.sheet(narc, Gen4Graphics, class) +-- +-- `dpOrder`: decrypt in Diamond and Pearl's order (last halfword first), +-- which HeartGold and SoulSilver use for these archives -- see +-- Gen4Graphics.decryptSpriteDP. +function Gen4Trgra.sheet(narc, Gen4Graphics, class, dpOrder) if not narc then return nil end local member = narc:get(class * Gen4Trgra.MEMBERS_PER_CLASS + Gen4Trgra.SLOT.sheet) if not (member and #member > 0) then return nil end local info = Gen4Graphics.tiles(member) if not (info and info.tilesX and info.tilesY) then return nil end return { - pixels = Gen4Graphics.decryptSprite(info.pixels), + pixels = (dpOrder and Gen4Graphics.decryptSpriteDP or Gen4Graphics.decryptSprite)(info.pixels), width = info.tilesX * 8, height = info.tilesY * 8, bitmap = info.bitmap, diff --git a/src/import/Gen4Versions.lua b/src/import/Gen4Versions.lua new file mode 100644 index 000000000..8368e53b9 --- /dev/null +++ b/src/import/Gen4Versions.lua @@ -0,0 +1,185 @@ +-- WHAT DIFFERS BETWEEN THE GEN 4 CARTRIDGES, for the extractor. +-- +-- RomExtractorGen4 was written for Platinum and keeps every cartridge path in +-- one table (PATH) and every message bank id in another (BANK). HeartGold and +-- SoulSilver hold the same kinds of archive in the same formats -- the +-- personal records, the move table, the item table, the trainer pair, the +-- learnsets and evolutions, the message archive -- but almost all of them +-- under numbered paths (/a/0/0/2), and their text in different bank numbers. +-- This module is those differences and nothing else, so a stage reads +-- `PATH.personal` whichever cartridge it is opening. +-- +-- THE PATHS come from pret/pokeheartgold's own file list +-- (src/import/Gen4HgssFiles.lua, generated by tools/gen4_hgss_files_gen.py). +-- +-- THE BANKS were found by looking, the same rule the Platinum table keeps: +-- pokeheartgold's files/msgdata/msg/msg_NNNN.json are the banks of +-- /a/0/2/7 in order, and each id below is the bank whose entries say what the +-- name claims -- 237 begins "-----", "BULBASAUR"; 751 "POUND"; 222 "Master +-- Ball"; 34 "Hardy"; 735 "NORMAL"; 279 "Mystery Zone"; 803 the English dex +-- entries, 812 the weights, 814 the heights, 816 "Seed Pokemon". 729 has no +-- JSON in the decomp because it is generated from the trainer data -- it sits +-- between 728 (the trainers' lines) and 730 (the classes), where Platinum's +-- 618 sits between 617 and 619. +-- +-- STAGES lists which of RomExtractorGen4's stages a version can run today. +-- Platinum runs all of them. A stage missing from a version's list is one whose +-- format or tables have not been checked against that cartridge yet, and is +-- recorded as not ported rather than run against data it would misread. +-- +-- HGSS runs the data stages and the FIELD's: encounters (its own 0xC4 record, +-- Gen4Encounters.parseHgss), maps (its header layout, Gen4MapHeaders.parseHgss, +-- read out of an ARM9 that is BLZ-compressed -- NdsBlz), events (Platinum's +-- format, measured to the byte), regions, scripts (its own 853 commands, +-- Gen4HgssScriptOps, renamed into Platinum's by Gen4HgssScriptAliases) and the +-- link between them. The art -- tilesets, terrain, models, overworld sprites, +-- fonts -- is still Platinum-only, and so is everything after it. + +local Files = require("src.import.Gen4HgssFiles") + +local V = {} + +V.FAMILY = { platinum = "platinum", heartgold = "hgss", soulsilver = "hgss" } + +local hgssPaths = { + text = Files.byName.msgdata_msg, + personal = Files.byName.poketool_personal_personal, + learnsets = Files.byName.poketool_personal_wotbl, + evolutions = Files.byName.poketool_personal_evo, + moves = Files.byName.poketool_waza_waza_tbl, + items = Files.byName.itemtool_itemdata_item_data, + trainerData = Files.byName.poketool_trainer_trdata, + trainerParty = Files.byName.poketool_trainer_trpoke, + trainerMessages = Files.byName.poketool_trmsg_trtbl, + trainerMessageOffsets = Files.byName.poketool_trmsg_trtblofs, + matrices = Files.byName.fielddata_mapmatrix_map_matrix, + land = Files.byName.fielddata_landdata_land_data, + events = Files.byName.fielddata_eventdata_zone_event, + scripts = Files.byName.fielddata_script_scr_seq, + fonts = Files.byName.graphic_font, + overworld = Files.byName.data_mmodel_mmodel, + pokegra = Files.byName.poketool_pokegra_pokegra, + otherpoke = Files.byName.poketool_pokegra_otherpoke, + growthTables = Files.byName.poketool_personal_growtbl, + pokeHeight = Files.byName.poketool_pokegra_height, + title = Files.byName.demo_title_titledemo, + intro = Files.byName.demo_intro_intro, + -- not named by the decomp's file list; identified in the cartridge -- 106 + -- eight-byte area records, 106 BTX0 map texture sets, 340 BMD0 exterior + -- buildings, 222 interior ones, 104 building texture sets + areaData = "/a/0/4/2", + areaBuild = "/a/0/4/3", + mapTextures = "/a/0/4/4", + buildingModels = "/a/0/4/0", + interiorBuildingModels = "/a/1/4/8", + buildingTextures = "/a/0/7/0", + -- the window frames: LoadUserFrameGfx1/2 (asm/render_window.s) read NARC + -- 0x26 -- a message box's tiles at frame+2, its palette at frame+26 + winframe = "/a/0/3/8", + -- trainer pictures: sub_02070D3C (src/pokemon.c) opens NARC_a_0_5_8 for + -- the opponent's front and NARC_a_0_0_6 for the player's back, five + -- members per class (NCGR, NCLR, NCER, NANR, NCBR) -- Platinum's stride + trainerFront = "/a/0/5/8", + trainerBack = "/a/0/0/6", +} + +-- /a/0/3/8 IS PLATINUM'S pl_winframe.narc WITH ONE MEMBER INSERTED: the same +-- 24 NCGRs (identical sizes, member for member), then one more NCGR of 320 +-- bytes at 24, then Platinum's 24..49 one slot later -- which is exactly the +-- loader's arithmetic, a message box's palette at frame+26 where Platinum's is +-- at frame+25, and the standard palette at 25 (0x19). So the names are +-- Platinum's with that one inserted, and Gen4Archives.groups pairs them. +local function hgssWinframeNames() + local pl = require("src.import.Gen4Archives").PACKED["/graphic/pl_winframe.narc"] + if not pl then return nil end + local out = {} + for piece in pl:gmatch("[^|]+") do out[#out + 1] = piece end + table.insert(out, 25, "hgss_extra_24.NCGR") + return table.concat(out, "|") +end + +V.OVERRIDES = { + heartgold = { + path = setmetatable({ encounters = Files.byName.fielddata_encountdata_g_enc_data }, { __index = hgssPaths }), + bank = { + species = 237, dexEntry = 803, dexWeight = 812, dexHeight = 814, dexCategory = 816, + move = 750, moveUpper = 751, moveDescription = 749, + item = 222, itemDescription = 221, + nature = 34, typeName = 735, + trainer = 729, trainerClass = 730, trainerClassArticle = 731, trainerMessages = 728, + mapLabel = 279, + }, + }, +} +V.OVERRIDES.soulsilver = { + path = setmetatable({ encounters = Files.byName.fielddata_encountdata_s_enc_data }, { __index = hgssPaths }), + bank = V.OVERRIDES.heartgold.bank, +} + +-- MEMBER NAMES for HGSS archives that the stages look members up by, where +-- pret has none. Each is proven against Platinum's archive of the same role: +-- fonts (/a/0/1/6): 0 and 1 differ from Platinum's font_system/font_message +-- in 45 bytes of 33,101 (and Platinum's two are identical to each other), 2 +-- from font_subscreen in 27, 3 IS font_unown, 5..8 ARE Platinum's 4..7; +-- 4, 9 and 10 have no Platinum twin. FontID order (src/font.c +-- sFontArcParam) loads 0..4 and 10. +V.MEMBER_NAMES = { + fonts = { "font_system.NFGR", "font_message.NFGR", "font_subscreen.NFGR", "font_unown.NFGR", + "font_hgss_04.NFGR", "font_special_chars.NCGR.lz", "screen_indicators.NCGR", "font.NCLR", + "screen_indicators.NCLR", "hgss_09.bin", "font_hgss_10.NFGR" }, +} + +-- the stages each family runs today (nil: every stage, the Platinum list) +V.STAGES = { + hgss = { "text", "species", "moves", "items", "trainers", "gen4_trainer_messages", "encounters", "maps", "events", "regions", "tilesets", "constants", "overworld", "species_sprites", "trainer_sprites", "heights", "field", "scripts", "link", "terrain", "models", "frames", "fonts", "battle_graphics" }, +} + +function V.family(version) return V.FAMILY[version] or "platinum" end +function V.isHgss(version) return V.family(version) == "hgss" end + +-- Swap a version's paths and banks into the extractor's two tables, keeping +-- Platinum's so the next extractor in the same process starts clean. +local NONE = {} +local saved = { path = {}, bank = {} } +local function restore(dst, keep) + for k, v in pairs(keep) do if v == NONE then dst[k] = nil else dst[k] = v end end +end +-- every key of a table and of the tables behind its __index +local function keys(t) + local out, seen = {}, {} + while t do + for k, v in pairs(t) do if not seen[k] then seen[k] = true; out[k] = v end end + local mt = getmetatable(t) + t = mt and mt.__index + end + return out +end +function V.apply(version, PATH, BANK) + restore(PATH, saved.path) + restore(BANK, saved.bank) + saved = { path = {}, bank = {} } + -- the script dispatcher's bands follow the cartridge too + require("src.import.Gen4ScriptBands").setFamily(V.family(version)) + -- ...and the member names HGSS's numbered archives are looked up by + if V.family(version) == "hgss" then + local Archives = require("src.import.Gen4Archives") + local names = hgssWinframeNames() + if names then + Archives.PACKED[hgssPaths.winframe] = names + Archives.cache[hgssPaths.winframe] = nil + end + end + local o = V.OVERRIDES[version] + if not o then return false end + for k, v in pairs(keys(o.path)) do + saved.path[k] = (PATH[k] == nil) and NONE or PATH[k] + PATH[k] = v + end + for k, v in pairs(keys(o.bank)) do + saved.bank[k] = (BANK[k] == nil) and NONE or BANK[k] + BANK[k] = v + end + return true +end + +return V diff --git a/src/import/Gen5Data.lua b/src/import/Gen5Data.lua new file mode 100644 index 000000000..bfd1fed4e --- /dev/null +++ b/src/import/Gen5Data.lua @@ -0,0 +1,183 @@ +-- BLACK AND WHITE'S RECORD FORMATS: personal data, learnsets, evolutions, +-- moves, items and trainers, out of their NARCs. +-- +-- pokeblack (squiddonaut/pokeblack, which builds this cartridge) has not yet +-- decompiled the code that reads these, so the layouts are the ones the Gen 5 +-- research community documents -- and each is PROVEN against the cartridge +-- rather than trusted: tools/gen5_import_check.lua reads Bulbasaur's base +-- stats (45/49/49/45/65/65, Grass/Poison, Overgrow/Chlorophyll), Pound +-- (Normal, physical, 40 power, 100 accuracy, 35 PP), the Master Ball and a +-- Potion's prices, and the Striaton leaders' teams back out of them. An offset +-- that is wrong fails one of those. +-- +-- Field names are the engine's own (what src/import/Gen4Species and friends +-- write), so Gen 5 data lands in the tables the battle and field code already +-- read. + +local G = {} + +local function u8(s, at) return s:byte(at + 1) or 0 end +local function u16(s, at) return u8(s, at) + u8(s, at + 1) * 256 end +local function s8(s, at) local v = u8(s, at); return v >= 128 and v - 256 or v end +local function u32(s, at) return u16(s, at) + u16(s, at + 2) * 65536 end + +-- Gen 5's types: Gen 4's order with the ??? type taken out (17 of them) +G.TYPES = { [0] = "NORMAL", "FIGHTING", "FLYING", "POISON", "GROUND", "ROCK", "BUG", "GHOST", "STEEL", + "FIRE", "WATER", "GRASS", "ELECTRIC", "PSYCHIC", "ICE", "DRAGON", "DARK" } +G.GROWTH = { [0] = "MEDIUM_FAST", "ERRATIC", "FLUCTUATING", "MEDIUM_SLOW", "FAST", "SLOW" } +G.CATEGORY = { [0] = "status", "physical", "special" } + +-- PERSONAL (0x3C bytes) +-- 00 hp atk def spe spa spd 06 type1 type2 08 catch 09 stage +-- 0A u16 EV yields, two bits each: hp atk def spe spa spd +-- 0C u16 item1 item2 item3 12 gender 13 hatch cycles 14 friendship +-- 15 growth 16 egg groups x2 18 abilities x3 (the third the hidden one) +-- 1B flee 1C u16 form stats offset 1E u16 form sprite offset 20 form count +-- 21 colour (low six bits) 22 u16 base exp 24 u16 height 26 u16 weight +-- 28 TM/HM bits (13 bytes) +function G.personal(s) + if type(s) ~= "string" or #s < 0x28 then return nil end + local ev = u16(s, 0x0A) + local function evAt(k) return math.floor(ev / 4 ^ k) % 4 end + local t1, t2 = u8(s, 6), u8(s, 7) + local p = { + baseStats = { hp = u8(s, 0), attack = u8(s, 1), defense = u8(s, 2), speed = u8(s, 3), + spatk = u8(s, 4), spdef = u8(s, 5) }, + typeIds = { t1, t2 }, + types = { G.TYPES[t1], G.TYPES[t2] }, + catchRate = u8(s, 8), stage = u8(s, 9), + evYield = { hp = evAt(0), attack = evAt(1), defense = evAt(2), speed = evAt(3), spatk = evAt(4), spdef = evAt(5) }, + heldItems = { common = u16(s, 0x0C), rare = u16(s, 0x0E), dream = u16(s, 0x10) }, + genderRatio = u8(s, 0x12), hatchCycles = u8(s, 0x13), baseFriendship = u8(s, 0x14), + expRate = u8(s, 0x15), growthRate = G.GROWTH[u8(s, 0x15)], + eggGroups = { u8(s, 0x16), u8(s, 0x17) }, + abilityIds = { u8(s, 0x18), u8(s, 0x19), u8(s, 0x1A) }, + fleeRate = u8(s, 0x1B), + formStatsIndex = u16(s, 0x1C), formSpriteIndex = u16(s, 0x1E), formCount = u8(s, 0x20), + bodyColor = u8(s, 0x21) % 64, + baseExp = u16(s, 0x22), height = u16(s, 0x24), weight = u16(s, 0x26), + } + -- the Gen 4 cache's spelling as well, so the engine's existing readers work + p.baseStats.spAttack, p.baseStats.spDefense = p.baseStats.spatk, p.baseStats.spdef + if t2 == t1 then p.types = { G.TYPES[t1] } end + local tm = {} + for b = 0, 12 do + local byte = u8(s, 0x28 + b) + for k = 0, 7 do if math.floor(byte / 2 ^ k) % 2 == 1 then tm[#tm + 1] = b * 8 + k end end + end + p.machineBits = tm + return p +end + +-- LEARNSET: { u16 move, u16 level } until 0xFFFF +function G.learnset(s) + local out = {} + if type(s) ~= "string" then return out end + for at = 0, #s - 4, 4 do + local move, level = u16(s, at), u16(s, at + 2) + if move == 0xFFFF then break end + out[#out + 1] = { move = move, level = level } + end + return out +end + +-- EVOLUTIONS: seven { u16 method, u16 parameter, u16 species } +function G.evolutions(s) + local out = {} + if type(s) ~= "string" then return out end + for at = 0, math.min(#s, 42) - 6, 6 do + local method, param, target = u16(s, at), u16(s, at + 2), u16(s, at + 4) + if method ~= 0 and target ~= 0 then out[#out + 1] = { method = method, param = param, species = target } end + end + return out +end + +-- MOVE (0x24 bytes) +-- 00 type 01 effect class 02 category 03 power 04 accuracy 05 pp +-- 06 priority (signed) 07 hits (min low nibble, max high) +-- 08 u16 condition 0A condition chance 0B condition kind 0C/0D turns min/max +-- 0E crit stage 0F flinch 10 u16 effect 12 recoil/drain (signed) 13 heal +-- 14 target 15-17 stats changed 18-1A by how much (signed) 1B-1D chances +-- 20 u32 flags +function G.move(s) + if type(s) ~= "string" or #s < 0x24 then return nil end + local t = u8(s, 0) + local cat = u8(s, 2) + return { + typeId = t, type = G.TYPES[t], class = G.CATEGORY[cat], category = G.CATEGORY[cat], + effectClass = u8(s, 1), power = u8(s, 3), accuracy = u8(s, 4), pp = u8(s, 5), + priority = s8(s, 6), hitsMin = u8(s, 7) % 16, hitsMax = math.floor(u8(s, 7) / 16), + condition = u16(s, 8), conditionChance = u8(s, 0x0A), conditionKind = u8(s, 0x0B), + turnsMin = u8(s, 0x0C), turnsMax = u8(s, 0x0D), critStage = u8(s, 0x0E), flinch = u8(s, 0x0F), + effect = u16(s, 0x10), recoil = s8(s, 0x12), heal = u8(s, 0x13), target = u8(s, 0x14), + statChanges = { + { stat = u8(s, 0x15), amount = s8(s, 0x18), chance = u8(s, 0x1B) }, + { stat = u8(s, 0x16), amount = s8(s, 0x19), chance = u8(s, 0x1C) }, + { stat = u8(s, 0x17), amount = s8(s, 0x1A), chance = u8(s, 0x1D) }, + }, + flags = u32(s, 0x20), + } +end + +-- ITEM (0x24 bytes): the fields the bag, the shops and the battle read +-- 00 u16 price / 10 02 hold effect 03 hold parameter 04 natural gift effect +-- 05 fling effect 06 fling power 07 natural gift power +-- 08 u16 natural gift type (low five bits), flags, and the POCKET in bits +-- 7..10: Master Ball 0 (Items), Potion 1 (Medicine), TM01 2, Cheri 3 +-- (Berries), Xtransceiver 4 (Key Items) -- what script 0xBB answers. +-- Kept as bwPocket: Platinum's fieldPocket counts eight pockets +-- 0A field use 0B battle use 0C has battle stats 0D battle pocket +-- 0E consumable 0F sort order +function G.item(s) + if type(s) ~= "string" or #s < 0x10 then return nil end + return { + -- STORED IN TENS: an Ultra Ball's record says 120, a Potion's 30 + price = u16(s, 0) * 10, holdEffect = u8(s, 2), holdEffectParam = u8(s, 3), + naturalGiftEffect = u8(s, 4), flingEffect = u8(s, 5), flingPower = u8(s, 6), + naturalGiftPower = u8(s, 7), naturalGiftType = u16(s, 8) % 32, + bwPocket = math.floor(u16(s, 8) / 128) % 16, + fieldUse = u8(s, 0x0A), battleUse = u8(s, 0x0B), battlePocket = u8(s, 0x0D), + consumable = u8(s, 0x0E), sortIndex = u8(s, 0x0F), + } +end + +-- TRAINER (trdata, 0x14 bytes) +-- 00 format (bit 0 moves, bit 1 held items) 01 class 02 battle type +-- (0 single, 1 double, 2 triple, 3 rotation) 03 party size +-- 04 u16 items x4 0C u32 AI flags 10 healer 11 money rate 12 u16 prize item +G.BATTLE_TYPES = { [0] = "single", "double", "triple", "rotation" } +function G.trainer(s) + if type(s) ~= "string" or #s < 0x14 then return nil end + local fmt = u8(s, 0) + return { + hasMoves = fmt % 2 == 1, hasItems = math.floor(fmt / 2) % 2 == 1, + class = u8(s, 1), battleType = G.BATTLE_TYPES[u8(s, 2)] or "single", + doubleBattle = u8(s, 2) == 1 or nil, partySize = u8(s, 3), + items = { u16(s, 4), u16(s, 6), u16(s, 8), u16(s, 0x0A) }, + aiFlags = u32(s, 0x0C), healer = u8(s, 0x10) ~= 0, moneyRate = u8(s, 0x11), prizeItem = u16(s, 0x12), + } +end + +-- TRAINER PARTY (trpoke): per Pokemon { u8 IV difficulty, u8 gender/ability, +-- u8 level, u8 0, u16 species, u16 form } then u16 item if the trainer has +-- items, then four u16 moves if it has moves +function G.party(s, tr) + local out = {} + if type(s) ~= "string" or not tr then return out end + local size = 8 + (tr.hasItems and 2 or 0) + (tr.hasMoves and 8 or 0) + for i = 0, tr.partySize - 1 do + local at = i * size + if at + size > #s then break end + local mon = { difficulty = u8(s, at), genderAbility = u8(s, at + 1), level = u8(s, at + 2), + species = u16(s, at + 4), form = u16(s, at + 6) } + local o = at + 8 + if tr.hasItems then mon.item = u16(s, o); o = o + 2 end + if tr.hasMoves then + mon.moves = { u16(s, o), u16(s, o + 2), u16(s, o + 4), u16(s, o + 6) } + end + out[#out + 1] = mon + end + return out +end + +return G diff --git a/src/import/Gen5Encounters.lua b/src/import/Gen5Encounters.lua new file mode 100644 index 000000000..87749faea --- /dev/null +++ b/src/import/Gen5Encounters.lua @@ -0,0 +1,79 @@ +-- BLACK AND WHITE'S WILD ENCOUNTERS (/a/1/2/6, 112 tables). +-- +-- A table is one season (232 bytes) or four (928: spring, summer, autumn, +-- winter, for the routes whose grass changes with the season): +-- +-- 00 u8 rates: grass, dark grass, rustling grass, surf, rippling surf, +-- fishing, rippling fishing, (padding) +-- 08 56 slots of { u16 species (low 11 bits) | form (high 5), u8 min, u8 max }: +-- 12 grass, 12 dark grass, 12 rustling, 5 surf, 5 rippling surf, +-- 5 fishing, 5 rippling fishing +-- +-- The layout is the research community's, confirmed against the cartridge by +-- tools/gen5_import_check.lua: Route 1 (zone 317, table 77) is Patrat and +-- Lillipup at levels 2-4. Written in Gen4Encounters' shape -- grassRate, +-- grass, surf, the rods -- so Encounter.forMap reads it as it reads +-- Platinum's, with the Gen 5 kinds beside it. + +local E = {} + +E.SEASON_BYTES = 232 +E.GRASS_RATES = { 20, 20, 10, 10, 10, 10, 5, 5, 4, 4, 1, 1 } +E.WATER_RATES = { 60, 30, 5, 4, 1 } +E.FISH_RATES = { 40, 40, 15, 4, 1 } +E.SEASONS = { "spring", "summer", "autumn", "winter" } + +local function u16(s, o) return s:byte(o + 1) + s:byte(o + 2) * 256 end + +local function slots(s, base, at, n, rates) + local out = {} + for i = 0, n - 1 do + local o = base + at + i * 4 + local v = u16(s, o) + local minL, maxL = s:byte(o + 3), s:byte(o + 4) + out[i + 1] = { species = v % 2048, form = math.floor(v / 2048), level = minL, + minLevel = minL, maxLevel = maxL, chance = rates[i + 1] } + end + return out +end + +-- season(bytes, index) -> one season's table in Gen4Encounters' shape +function E.season(s, index) + local base = (index or 0) * E.SEASON_BYTES + if #s < base + E.SEASON_BYTES then return nil end + local r = function(i) return s:byte(base + i + 1) end + return { + family = "bw", + grassRate = r(0), + grass = slots(s, base, 8, 12, E.GRASS_RATES), + darkGrassRate = r(1), darkGrass = slots(s, base, 8 + 48, 12, E.GRASS_RATES), + rustlingRate = r(2), rustling = slots(s, base, 8 + 96, 12, E.GRASS_RATES), + surf = { rate = r(3), slots = slots(s, base, 8 + 144, 5, E.WATER_RATES) }, + ripplingSurf = { rate = r(4), slots = slots(s, base, 8 + 164, 5, E.WATER_RATES) }, + superRod = { rate = r(5), slots = slots(s, base, 8 + 184, 5, E.FISH_RATES) }, + ripplingFishing = { rate = r(6), slots = slots(s, base, 8 + 204, 5, E.FISH_RATES) }, + } +end + +-- all(archive) -> { [table] = spring's view, with .seasons = all of them } +function E.all(archive) + local out = {} + for i = 0, archive.count - 1 do + local s = archive:get(i) + if s and #s >= E.SEASON_BYTES then + local seasons = {} + for k = 0, math.floor(#s / E.SEASON_BYTES) - 1 do seasons[E.SEASONS[k + 1] or k] = E.season(s, k) end + -- spring is the table itself; the other three ride beside it (a copy + -- would only repeat it, and a reference to itself cannot be written) + local base = seasons.spring + base.id = i + if #s >= E.SEASON_BYTES * 4 then + base.seasons = { summer = seasons.summer, autumn = seasons.autumn, winter = seasons.winter } + end + out[i] = base + end + end + return out +end + +return E diff --git a/src/import/Gen5Events.lua b/src/import/Gen5Events.lua new file mode 100644 index 000000000..ccc8fbe3e --- /dev/null +++ b/src/import/Gen5Events.lua @@ -0,0 +1,149 @@ +-- BLACK AND WHITE'S MAP EVENTS (/a/1/2/5, one file per zone, 428). +-- +-- u32 size of what follows the counts, then u8 counts: interactables, NPCs, +-- warps, triggers -- and the records in that order, 20 / 36 / 20 / 22 bytes. +-- 427 of the 428 files add up to the byte with those sizes. +-- +-- Positions are ABSOLUTE tiles on the zone's matrix (Striaton's NPCs stand at +-- x 740..795 on a map whose corner is 736), so every one is taken back to +-- the map by its origin. Field by field, read from Striaton (zone 6): +-- +-- interactable u16 script, u16 kind, u16, u16, u32 x, u32 y, ... +-- NPC u16 id, u16 sprite, u16 movement, u16 type, u16 flag, +-- u16 script, u16 facing (0 up 1 down 2 left 3 right), ... +-- u16 x @28, u16 y @30, and a PAIR at @14 / @16 whose +-- meaning is the object's: sprite 185 (no picture -- 54 +-- objects) is an invisible blocker that wide and tall +-- (Striaton's gym curtains are 7 x 1 at (9, 34/24/14)); a +-- trainer's @14 is its sight range (the gym's two: 6); on +-- anyone else the pair is the wander range +-- warp u16 destination zone, u16 destination warp, ..., +-- u16 x @8 and u16 y @12 in pixels (tile * 16 + 8) +-- trigger u16 script, u16 value, u16 var, u16 (0 in every one +-- read), u16, u16 x, u16 y, u16 width, u16 height -- the +-- VALUE is the second halfword: Accumula's Plasma speech fires +-- at var 0x407A == 2, after Juniper's Pokemon Center tour sets +-- 2, and Route 1's exit at 0x407C == 1, after her demonstration +-- +-- Written in the shape Platinum's map defs carry (objects, warps, signs, +-- coordEvents), so the Gen 4 overworld places them. + +local E = {} + +local function u8(s, o) return s:byte(o + 1) or 0 end +local function u16(s, o) return u8(s, o) + u8(s, o + 1) * 256 end +local function u32(s, o) return u16(s, o) + u16(s, o + 2) * 65536 end + +function E.parse(d) + if type(d) ~= "string" or #d < 8 then return nil end + local nf, nn, nw, nt = u8(d, 4), u8(d, 5), u8(d, 6), u8(d, 7) + if 4 + nf * 20 + nn * 36 + nw * 20 + nt * 22 ~= u32(d, 0) then return nil, "sizes do not add up" end + local out = { signs = {}, npcs = {}, warps = {}, triggers = {} } + local at = 8 + for i = 0, nf - 1 do + local o = at + i * 20 + out.signs[i + 1] = { index = i, script = u16(d, o), kind = u16(d, o + 2), x = u32(d, o + 8), y = u32(d, o + 12) } + end + at = at + nf * 20 + for i = 0, nn - 1 do + local o = at + i * 36 + out.npcs[i + 1] = { index = i, localId = u16(d, o), sprite = u16(d, o + 2), movementType = u16(d, o + 4), + trainerType = u16(d, o + 6), flag = u16(d, o + 8), script = u16(d, o + 10), direction = u16(d, o + 12), + x = u16(d, o + 28), y = u16(d, o + 30), paramA = u16(d, o + 14), paramB = u16(d, o + 16) } + end + at = at + nn * 36 + for i = 0, nw - 1 do + local o = at + i * 20 + out.warps[i + 1] = { index = i, destZone = u16(d, o), destWarp = u16(d, o + 2), + kind = d:byte(o + 5), x = math.floor(u16(d, o + 8) / 16), y = math.floor(u16(d, o + 12) / 16), + width = math.max(u16(d, o + 14), 1), height = math.max(u16(d, o + 16), 1) } + end + at = at + nw * 20 + for i = 0, nt - 1 do + local o = at + i * 22 + out.triggers[i + 1] = { index = i, script = u16(d, o), var = u16(d, o + 4), value = u16(d, o + 2), + x = u16(d, o + 10), y = u16(d, o + 12), width = u16(d, o + 14), height = u16(d, o + 16) } + -- THE OTHER SHAPE: halfword +8 is 1 on nine triggers (Castelia's, Route + -- 4's ...), and then an ELEVATION sits between the position and the + -- extent -- x, y, a signed z, width, height. Read as the first shape, + -- Castelia's 1 x 8 strip at (4, 24) was 65531 tiles wide. + if u16(d, o + 8) == 1 then + local t = out.triggers[i + 1] + local z = u16(d, o + 14) + t.elevation = z >= 0x8000 and z - 0x10000 or z + t.width, t.height = u16(d, o + 16), u16(d, o + 18) + end + end + return out +end + +-- attach(def, events, spriteFor) -- fills a Gen 4-shaped map def's lists +E.BLOCKER_SPRITE = 185 +function E.attach(def, ev, spriteFor) + if not (def and ev) then return end + local ox, oy = def.originX or 0, def.originY or 0 + def.objects, def.warps, def.signs, def.coordEvents = {}, {}, {}, {} + for _, n in ipairs(ev.npcs) do + local sprite, name = spriteFor(n.sprite) + def.objects[#def.objects + 1] = { index = n.index + 1, localId = n.localId, graphicsId = n.sprite, + sprite = sprite, spriteName = name, x = n.x - ox, y = n.y - oy, direction = n.direction, + movementType = n.movementType, movementRangeX = 0, movementRangeY = 0, trainerType = n.trainerType, + -- the same flag namespace the script engine's setflag/clearflag write + -- (Gen4ScriptVM.flagName), so a script hiding an NPC hides THIS one + flag = n.flag, eventFlag = n.flag ~= 0 and ("FLAG_G4_%04X"):format(n.flag) or nil, + script = n.script, elevation = 0, gen5 = true } + local obj = def.objects[#def.objects] + if n.sprite == E.BLOCKER_SPRITE then + -- never drawn, always in the way: the state `buried` already means + -- (OverworldController seeds it from the def). Striaton's gym curtains + -- are three of these, walked aside by the right doorway's script. + obj.gen5Blocker, obj.buried = true, true + obj.footprintW, obj.footprintH = math.max(n.paramA or 1, 1), math.max(n.paramB or 1, 1) + elseif n.script >= 3000 and n.script < 7000 then + -- A TRAINER, by its script's band exactly as on Gen 4: 3000.. single, + -- 5000.. double, the remainder the trainer id (Striaton's gym runs + -- 3009 / 3010 -- Waiter Maxwell and Waitress Tia, the waiters standing + -- there). The sight range is the pair's first half; which ways it looks + -- is Gen 4's rule on the trainer type (2 all round, 1 its facing). + local double = n.script >= 5000 + obj.trainer = { id = n.script - (double and 5000 or 3000), battler = double and 1 or 0 } + obj.sightRange = n.paramA + local ty = n.trainerType or 0 + obj.sightWays = ty == 2 and 4 or ((ty == 1 or (ty >= 4 and ty <= 8)) and 1 or 0) + else + obj.movementRangeX, obj.movementRangeY = n.paramA or 0, n.paramB or 0 + end + end + for _, w in ipairs(ev.warps) do + def.warps[#def.warps + 1] = { index = w.index + 1, x = w.x - ox, y = w.y - oy, + destMap = ("Z%03d"):format(w.destZone), destHeader = w.destZone, destWarp = w.destWarp + 1, + kind = w.kind, width = w.width, height = w.height } + end + -- (`destWarp` is 1-based, as the engine indexes `warps[destWarp]` and the + -- Gen 4 importer writes it; the cartridge's number is 0-based.) + -- A WARP COVERS A RECTANGLE: halfwords +14 / +16 are its width and height + -- (a gym's exit mat 3 x 1, Route 2's gate edge 1 x 3, the Dreamyard's 1 x + -- 4). The extra cells go AFTER every real warp, so a destination's + -- `destWarp` still indexes the list as the cartridge numbers it. + for _, w in ipairs(ev.warps) do + for dy = 0, (w.height or 1) - 1 do + for dx = 0, (w.width or 1) - 1 do + if dx > 0 or dy > 0 then + def.warps[#def.warps + 1] = { index = #def.warps + 1, extentOf = w.index + 1, + x = w.x - ox + dx, y = w.y - oy + dy, destMap = ("Z%03d"):format(w.destZone), + destHeader = w.destZone, destWarp = w.destWarp + 1, kind = w.kind } + end + end + end + end + for _, s in ipairs(ev.signs) do + def.signs[#def.signs + 1] = { index = s.index + 1, x = s.x - ox, y = s.y - oy, script = s.script, + kind = s.kind, elevation = 0 } + end + for _, t in ipairs(ev.triggers) do + def.coordEvents[#def.coordEvents + 1] = { index = t.index + 1, x = t.x - ox, y = t.y - oy, + width = t.width, height = t.height, script = t.script, var = t.var, value = t.value, elevation = 0 } + end +end + +return E diff --git a/src/import/Gen5Field.lua b/src/import/Gen5Field.lua new file mode 100644 index 000000000..b6991afc4 --- /dev/null +++ b/src/import/Gen5Field.lua @@ -0,0 +1,708 @@ +-- BLACK AND WHITE'S FIELD, IN THE SHAPE THE GEN 4 RENDERER READS. +-- +-- The user's ask was that Gen 5 use Platinum's camera and its controls, which +-- means the Unova field has to come out as the tables Gen4Ground draws -- +-- gen4_terrain, its matrices and chunks, map defs cut from a shared grid -- +-- rather than as a third renderer. That is possible because the formats agree +-- where it matters: +-- +-- * every chunk's model is an NSBMD (all 649 of /a/0/0/8 pack through +-- Gen4ModelPack and pass its round-trip check), and +-- * each zone names ONE texture set by its area field, /a/0/1/4 (Striaton's +-- three chunks wear 39 textures, every one of them in set 6, its area). +-- +-- PERMISSIONS are translated tile by tile into the Gen 4 word the engine walks +-- on: the behaviour in the low byte, bit 15 for "blocked". In a WB chunk each +-- tile is 8 bytes, and an interior shows which are which -- a 13 x 15 room's +-- floor is byte 4 = 0x00 / byte 6 = 0x80, its walls 0x01 / 0x81, its counters +-- 0xD4 / 0x01: byte 4 is the behaviour and bit 0 of byte 6 the collision. +-- (Bytes 0..3 vary with slopes and are not read yet; the ground's height comes +-- from the model, as it does where a Gen 4 chunk has no BDHC.) +-- +-- Map ids are "Z" and carry the zone's place name from system bank 89. +-- The camera is Black and White's own: the zone header's +0x1C bits 9..15 +-- index /a/0/6/0 (see THE CAMERAS below), carried on the def as gen5Camera. + +local M = require("src.import.Gen5Maps") +local Nsbmd = require("src.import.Gen4Nsbmd") +local Pack = require("src.import.Gen4ModelPack") +local Models = require("src.import.Gen4Models") +local Terrain = require("src.import.Gen4Terrain") +local Gen4Maps = require("src.import.Gen4Maps") +local Graphics = require("src.import.Gen4Graphics") + +local F = {} + +F.PATH = { + zones = "/a/0/1/2", matrices = "/a/0/0/9", chunks = "/a/0/0/8", + textures = "/a/0/1/4", events = "/a/1/2/5", +} +F.CHUNK = 32 +F.NO_CHUNK = 0xFFFF + +-- permissions(raw WB section) -> 1024 Gen 4 permission words, little-endian +function F.permissions(raw) + if type(raw) ~= "string" or #raw < 4 + F.CHUNK * F.CHUNK * 8 then return nil end + local out = {} + for t = 0, F.CHUNK * F.CHUNK - 1 do + local at = 4 + t * 8 + local behaviour = raw:byte(at + 5) + local blocked = raw:byte(at + 7) % 2 == 1 + local word = behaviour + (blocked and 0x8000 or 0) + out[t + 1] = string.char(word % 256, math.floor(word / 256)) + end + return table.concat(out) +end + +-- HEIGHTS, from the same 8-byte tile records: bytes 0-1 are 0 on a flat +-- tile, and then bytes 2-3 are its height in world units (16 to a tile), +-- signed. Striaton's ground is 0 with ledges at 24; the Trio Badge gym's +-- dais is 14 on exactly its 7 x 6 tiles; Nacrene's raised streets are 120. +-- A non-zero bytes 0-1 (3% of tiles: the stairs and slopes, 0x0008 on the +-- gym's front step) is a sloped surface whose encoding is not read yet, so +-- those tiles are left out and take their neighbours' height. +-- heights(raw) -> { [tile + 1] = height } for the flat, non-zero tiles +function F.heights(raw) + if type(raw) ~= "string" or #raw < 4 + F.CHUNK * F.CHUNK * 8 then return nil end + local out = {} + for t = 0, F.CHUNK * F.CHUNK - 1 do + local at = 4 + t * 8 + if raw:byte(at + 1) == 0 and raw:byte(at + 2) == 0 then + local h = raw:byte(at + 3) + raw:byte(at + 4) * 256 + if h >= 0x8000 then h = h - 0x10000 end + if h ~= 0 then out[t + 1] = h end + end + end + return out +end + +-- the Gen 4 matrix shape (Gen4Maps.matrix) of a BW matrix +local function gen4Matrix(m, id) + if not m then return nil end + local maps, headers = {}, nil + for i = 1, m.width * m.height do maps[i] = m.chunks[i] or F.NO_CHUNK end + if m.zones then + headers = {} + for i = 1, m.width * m.height do headers[i] = m.zones[i] or 0 end + end + return { width = m.width, height = m.height, maps = maps, headers = headers, + name = ("matrix_%03d"):format(id) } +end + +-- BUILDINGS. A WB chunk's third section places them: u32 count, then per +-- building fx32 x, y, z, a u16 rotation (0 / 0x4000 / 0x8000 / 0xC000 -- +-- the four quarter turns, nothing else occurs in 2,320 placements) and a u16 +-- whose high byte is the building's id. The id is not an index into one +-- archive: it names a building IN THE AREA'S PACK, /a/2/2/9 outdoors (52 +-- packs, "AB") and /a/2/3/0 indoors (64). A pack is a table of entries: the +-- first half are each building's record, opening with its id, and the second +-- half are the models in the same order; the models carry no textures, which +-- are the pack's own set in /a/1/7/6 (outdoor) or /a/1/7/7 (indoor), one per +-- pack. +-- +-- WHICH PACK. Of the 19 outdoor zones whose placed ids fit exactly one pack, +-- 18 are pack = floor(area / 4) -- every outdoor area is 4k + 2, the texture +-- sets coming four to a place (a season each) -- and no zone-header or area +-- field predicts the pack better. The rule is used and the one zone it does +-- not explain is reported, not papered over. +-- +-- Indoors it is an offset: areas 216..236 are indoor packs 6..26 (every zone +-- whose ids fit one pack, e.g. 217 -> 7, 219 -> 9, 248 -> 38), so the 210s up +-- are pack = area - 210; an area below that is an outdoor-style place (a cave, +-- a gate) and takes the outdoor rule. +function F.packFor(area) + if area >= 210 then return "indoor", area - 210 end + return "outdoor", math.floor(area / 4) +end +F.BUILDING_PACKS = { outdoor = "/a/2/2/9", indoor = "/a/2/3/0" } +F.BUILDING_TEXTURES = { outdoor = "/a/1/7/6", indoor = "/a/1/7/7" } +F.PLACEMENT_BYTES = 16 + +function F.placements(raw) + if type(raw) ~= "string" or #raw < 4 then return {} end + local n = raw:byte(1) + raw:byte(2) * 256 + raw:byte(3) * 65536 + raw:byte(4) * 16777216 + if 4 + n * F.PLACEMENT_BYTES ~= #raw then return {} end + local function s32(o) + local a, b, c, d = raw:byte(o + 1, o + 4) + local v = a + b * 256 + c * 65536 + d * 16777216 + return v >= 2147483648 and v - 4294967296 or v + end + local out = {} + for i = 0, n - 1 do + local o = 4 + i * F.PLACEMENT_BYTES + local rot = raw:byte(o + 13) + raw:byte(o + 14) * 256 + -- Z IS STORED NEGATED (north positive): read as it stands, Nuvema's + -- living-room furniture stood half a chunk south of its own floor (the + -- floor spans z -250..-72, the pieces 120..232) and Striaton's gym a + -- block away from its door; negated, both sit where the doors and walls + -- say. The rotation is used as stored. + out[i + 1] = { model = raw:byte(o + 16), x = s32(o) / 4096, y = s32(o + 4) / 4096, z = -s32(o + 8) / 4096, + rotY = rot / 65536 * 360, gen5 = true } + end + return out +end + +-- pack(bytes) -> { ids = { [i] = id }, models = { [i] = BMD0 bytes } } +function F.pack(bytes) + if type(bytes) ~= "string" or bytes:sub(1, 2) ~= "AB" then return nil end + local function u32(o) local a, b, c, d = bytes:byte(o + 1, o + 4); return a + b * 256 + c * 65536 + d * 16777216 end + local n = bytes:byte(3) + bytes:byte(4) * 256 + local ids, models = {}, {} + for i = 0, n - 1 do + local a, e = u32(4 + i * 4), u32(4 + (i + 1) * 4) + if a < e and e <= #bytes then + local seg = bytes:sub(a + 1, e) + if seg:sub(1, 4) == "BMD0" then models[#models + 1] = seg else ids[#ids + 1] = u32(a) end + end + end + return { ids = ids, models = models } +end + +-- packs(rom, kind, save) -> { [pack] = { label, path, models = { [id + 1] = packed } } } +function F.buildingPacks(rom, kind, save) + local Narc = require("src.import.NarcArchive") + local packBytes, texBytes = rom:read(F.BUILDING_PACKS[kind]), rom:read(F.BUILDING_TEXTURES[kind]) + local packs, texArc = packBytes and Narc.parse(packBytes), texBytes and Narc.parse(texBytes) + local out, models, shapes = {}, 0, 0 + if not packs then return out, 0, 0 end + for p = 0, packs.count - 1 do + local pk = F.pack(packs:get(p)) + local tb = texArc and texArc:get(p) + if tb and Graphics.isCompressed(tb) then tb = Graphics.decompress(tb) end + local textures = tb and Models.parse(tb) + local set = { label = ("%s buildings %d"):format(kind, p), path = F.BUILDING_PACKS[kind], models = {} } + for i, bmd in ipairs(pk and pk.models or {}) do + local id = pk.ids[i] + local parsed = Nsbmd.parse(bmd) + local model = parsed and parsed.models and parsed.models[1] + local packed = model and Pack.pack(model) + if packed and id then + local written = {} + for _, shape in ipairs(packed.shapes) do + local name = shape.texture + if name and textures then + if written[name] == nil then + written[name] = false + local index, palette + for k, t in ipairs(textures.textures) do if t.name == name then index = k end end + for k, pl in ipairs(textures.palettes or {}) do if pl.name == shape.palette then palette = k end end + local image = index and Models.decode(textures, tb, index, palette or 1) + local saved = image and save.image(("models/bw_%s/%02d/%d_%s"):format(kind, p, id, name:gsub("[^%w_%-]", "_")), + image, { texture = name, palette = shape.palette }) + written[name] = saved and saved.path or false + end + shape.image = written[name] or nil + end + shapes = shapes + 1 + end + packed.member = id + set.models[id + 1] = packed + models = models + 1 + end + end + out[p] = set + end + return out, models, shapes +end + +-- CHARACTERS (Gen5Overworld): every sprite id's model member made into a +-- sheet, keyed SPRITE_G4_ as Platinum's are. The frame order was read +-- off the sheets themselves: a 12-frame-plus sheet (the player's has 32) is +-- three a direction -- stand, step, step -- up, down, left, right, the run +-- after; a 7-frame NPC is up, up-step, down, down-step, then three side views +-- (left, left-step, right); fewer than that stand still. +function F.facingsFor(frames) + if frames >= 12 then + return { up = { 0, 1, 2 }, down = { 3, 4, 5 }, left = { 6, 7, 8 }, right = { 9, 10, 11 } } + elseif frames >= 7 then + return { up = { 0, 1, 1 }, down = { 2, 3, 3 }, left = { 4, 5, 5 }, right = { 6, 6, 6 } } + end + return nil +end + +function F.characters(rom, save) + local O = require("src.import.Gen5Overworld") + local Narc = require("src.import.NarcArchive") + local tb, mb = rom:read(O.TABLE), rom:read(O.MODELS) + local table_ = tb and O.table(Narc.parse(tb):get(0)) or {} + local arc = mb and Narc.parse(mb) + local sprites, done, written = {}, {}, 0 + for _, member in pairs(table_) do + if arc and not done[member] and member < arc.count then + done[member] = true + local b = arc:get(member) + local btx = b and ((b:sub(1, 4) == "BTX0") and b or O.btx(b)) + local sheet = btx and Models.sheet(btx, { color0Transparent = true }) + if sheet then + local key = ("SPRITE_G4_%03d"):format(member) + local entry = save.image(("overworld/bw_%03d"):format(member), sheet, { member = member }) + if entry then + local facings = F.facingsFor(sheet.frames) + sprites[key] = { id = key, image = entry.path, frames = sheet.frames, frameWidth = sheet.frameWidth, + frameHeight = sheet.frameHeight, walker = sheet.frames > 1, trueColor = true, + facings = facings, source = ("ROM:/a/0/4/9[%d]"):format(member) } + written = written + 1 + end + end + end + end + return sprites, table_, written +end + +-- build(x) -> tables; `x` is RomExtractorGen5 (archive(), string(), rom) +-- `save` = { image = function(rel, image, extra), binary = function(rel, bytes) } +function F.build(x, save) + local rom = x.rom + local Narc = require("src.import.NarcArchive") + local function arc(p) local b = rom:read(p); return b and Narc.parse(b) end + local zoneArc, mxArc, chunkArc, texArc = arc(F.PATH.zones), arc(F.PATH.matrices), arc(F.PATH.chunks), arc(F.PATH.textures) + local zones = M.zones(zoneArc:get(0)) + local report = {} + + -- chunks: packed models and translated permissions + local blob = Terrain.newBlob() + local terrainChunks, permissions, lands, refused, heightsOf = {}, {}, {}, {}, {} + -- NG ("no grid") chunks: no permissions, and a model at posScale 512 -- + -- eight times a WB chunk's 64 -- so each spans 4,096 units, 256 tiles. + -- A matrix made only of them is a RAIL map (Gen5Field's rails below). + local ngChunks = {} + for c = 0, chunkArc.count - 1 do + local chunk = M.chunk(chunkArc:get(c)) + local set = chunk and Nsbmd.parse(chunk.model) + local model = set and set.models and set.models[1] + local packed = model and Pack.pack(model) + if packed and Pack.verify(model, packed) then + local perms = chunk.permissions and F.permissions(chunk.permissions) or nil + permissions[c] = perms + heightsOf[c] = chunk.permissions and F.heights(chunk.permissions) or nil + lands[c] = { permissions = perms or string.rep("\0\128", F.CHUNK * F.CHUNK) } + if chunk.kind == "NG" then ngChunks[c] = true end + terrainChunks[c] = Terrain.append(blob, { packed = packed, permissions = perms, + objects = F.placements(chunk.buildings) }) + else + refused[c] = chunk and chunk.kind or "unparsed" + end + end + local geometry, heights, perms = Terrain.finish(blob) + + -- matrices, in both shapes the Gen 4 code reads + local matrices, gen4Matrices, layouts, grids = {}, {}, {}, {} + local function chunkFor(id) return id and id ~= F.NO_CHUNK and lands[id] or nil end + for i = 0, mxArc.count - 1 do + local m = M.matrix(mxArc:get(i)) + local g4 = gen4Matrix(m, i) + if g4 then + gen4Matrices[i] = g4 + grids[i] = Terrain.grid(g4) + local def = Gen4Maps.mapDef(g4, chunkFor) + if def then def.name = g4.name; layouts[i] = def end + end + end + + -- texture sets, with the palette each texture is worn with + local pairing = {} + for _, entry in pairs(terrainChunks) do + for _, shape in ipairs(entry.shapes or {}) do + if shape.texture and shape.palette then + pairing[shape.texture] = pairing[shape.texture] or {} + pairing[shape.texture][shape.palette] = (pairing[shape.texture][shape.palette] or 0) + 1 + end + end + end + local sets, setsBuilt = {}, 0 + for s = 0, texArc.count - 1 do + local bytes = texArc:get(s) + if bytes and Graphics.isCompressed(bytes) then bytes = Graphics.decompress(bytes) end + local parsed = bytes and Models.parse(bytes) + local list = parsed and Terrain.textures(parsed, bytes, pairing) + if list then + local entry = { textures = {} } + for _, tex in ipairs(list) do + local safe = tostring(tex.name):gsub("#", "__"):gsub("[^%w_%-]", "_") + local saved = save.image(("terrain/tex/%03d/%s"):format(s, safe), tex.image, + { texture = tex.texture, palette = tex.palette }) + if saved then entry.textures[tex.name] = saved end + end + sets[s] = entry + setsBuilt = setsBuilt + 1 + end + end + + -- THE CAMERAS: /a/0/6/0 is one table of 44-byte records (38 in Black), + -- read at index * 44 by the field camera (0x0218E260). Decoded: + -- 00 u32 distance (world units) 04 u16 pitch (0x25D8 = 53.2 degrees, + -- not negated as Platinum's is) 12 u16 the half field of view (0x0E38 + -- = 20 degrees) 20 VecFx32 look-at offset (the default lifts it 4) + -- The default's distance and FOV give 1.1 pixels a unit, the scale + -- Platinum's rows give. NOT DECODED: the near / far planes (the u16 at + -- 0x1A -- 0x40 on the default -- is taken as far = that x 16, near 16). + local cameras = {} + local camBytes = rom:read("/a/0/6/0") + local camArc = camBytes and Narc.parse(camBytes) + local camTable = camArc and camArc:get(0) + if camTable then + local function cu16(o) return camTable:byte(o + 1) + camTable:byte(o + 2) * 256 end + local function cs32(o) + local v = cu16(o) + cu16(o + 2) * 65536 + return (v >= 2147483648 and v - 4294967296 or v) / 4096 + end + for i = 0, math.floor(#camTable / 44) - 1 do + local o = i * 44 + cameras[i] = { id = ("bw_%d"):format(i), family = "bw", + distance = cu16(o) + cu16(o + 2) * 65536, + pitch = cu16(o + 4) * 360 / 65536, projection = "perspective", + halfFov = cu16(o + 0x12) * 360 / 65536, + near = 16, far = math.max(cu16(o + 0x1A), 32) * 16, + offset = { x = cs32(o + 0x20), y = cs32(o + 0x24), z = cs32(o + 0x28) } } + end + end + + -- THE RAILS (Black and White's non-grid ground: Castelia's ring, the + -- bridges, Victory Road). The chain, read off the field code: + -- zone -> a packed u16 table in the ARM9 right after the 11-entry rail + -- zone list (low 11 bits the zone, high 5 an index; 0x0209E026 in + -- Black, found by the list 28, 249, 214 that opens it) + -- -> /a/1/0/9[index] = { u16 rail file, u16 camera area } (0x021BB278) + -- -> /a/0/7/9[rail file]: a header of u32 offsets and four sections of + -- records, named in the cartridge's own bytes -- POINTS ("RE_POINT_n", + -- 112 bytes: four line slots, four direction keys, four widths, fx32 + -- x / y / z), LINES ("RE_LINE_n", 72 bytes: point a, point b, then + -- shape / camera references), 36-byte line shapes (section C; type 3 + -- and 5 / 6 seen) and 36-byte curve records (section D; type 1 = a + -- circle about the fx32 centre that follows -- Castelia's plaza). + -- Positions are world units; an NG chunk's model is posScale 512, eight + -- times a WB chunk's, which is why the points run past a 32-tile chunk. + -- Extracted here as data only: nothing walks on it yet. + local rails = {} + do + local arm9 = rom:arm9() + local key = string.char(0x1C, 0x00, 0xF9, 0x00, 0xD6, 0x00) + local at = arm9 and arm9:find(key, 1, true) + local railArc = rom:read("/a/0/7/9") and Narc.parse(rom:read("/a/0/7/9")) + local mapArc = rom:read("/a/1/0/9") and Narc.parse(rom:read("/a/1/0/9")) + if at and railArc and mapArc then + local function au16(o) return arm9:byte(o) + arm9:byte(o + 1) * 256 end + local tableAt = at + 22 + local function ru32(d, o) return d:byte(o + 1) + d:byte(o + 2) * 256 + d:byte(o + 3) * 65536 + d:byte(o + 4) * 16777216 end + local function rs32(d, o) local v = ru32(d, o) return v >= 2147483648 and v - 4294967296 or v end + local function name(d, o, n) return (d:sub(o + 1, o + n):gsub("[%z ]+$", "")) end + local files = {} + local function railFile(f) + if files[f] ~= nil then return files[f] end + local d = railArc:get(f) + if not d or #d < 0x1C then files[f] = false return false end + local h = {} for k = 0, 6 do h[k] = ru32(d, k * 4) end + local out = { file = f, points = {}, lines = {}, shapes = {}, curves = {} } + for i = 0, math.floor((h[3] - h[2]) / 0x70) - 1 do + local o = h[2] + i * 0x70 + local pt = { lines = {}, keys = {}, widths = {}, name = name(d, o + 0x40, 0x30) } + for k = 0, 3 do + local l = ru32(d, o + k * 4) + pt.lines[k + 1] = l ~= 0xFFFF and l or false + pt.keys[k + 1] = ru32(d, o + 0x10 + k * 4) + pt.widths[k + 1] = ru32(d, o + 0x20 + k * 4) + end + pt.x, pt.y, pt.z = rs32(d, o + 0x30) / 4096, rs32(d, o + 0x34) / 4096, rs32(d, o + 0x38) / 4096 + out.points[i] = pt + end + for i = 0, math.floor((h[4] - h[3]) / 0x48) - 1 do + local o = h[3] + i * 0x48 + local raw = {} for k = 0, 5 do raw[k + 1] = ru32(d, o + k * 4) end + out.lines[i] = { a = raw[1], b = raw[2], raw = raw, name = name(d, o + 0x18, 0x30) } + end + for i = 0, math.floor((h[5] - h[4]) / 0x24) - 1 do + local o = h[4] + i * 0x24 + local raw = {} for k = 0, 8 do raw[k + 1] = rs32(d, o + k * 4) end + out.shapes[i] = raw + end + for i = 0, math.floor((h[6] - h[5]) / 0x24) - 1 do + local o = h[5] + i * 0x24 + local kind = ru32(d, o) + out.curves[i] = { kind = kind, cx = rs32(d, o + 4) / 4096, cy = rs32(d, o + 8) / 4096, cz = rs32(d, o + 12) / 4096 } + end + files[f] = out + return out + end + for k = 0, 19 do + local v = au16(tableAt + k * 2) + local zone, index = v % 2048, math.floor(v / 2048) + local m = mapArc:get(index) + if m and #m >= 4 then + local f = m:byte(1) + m:byte(2) * 256 + local area = m:byte(3) + m:byte(4) * 256 + local rf = f ~= 0xFFFF and railFile(f) or nil + if rf then rails[zone] = { index = index, file = f, cameraArea = area ~= 0xFFFF and area or nil, data = rf } end + end + end + end + end + + -- WHICH MATRICES ARE ALL NG: drawn at eight times the chunk size, and + -- their map defs sized to match (after the defs are built) + local ngScale = {} + for i, g4 in pairs(gen4Matrices) do + local any, all = false, true + for _, c in ipairs(g4.maps or {}) do + if c ~= F.NO_CHUNK then any = true; if not ngChunks[c] then all = false end end + end + if any and all then ngScale[i] = 8 end + end + + -- map defs and the per-map terrain record, one per zone + local names = x:bank(89) + local maps, headers, terrainMaps = {}, {}, {} + local extentsByMatrix = {} + for id, z in pairs(zones) do + local mapId = ("Z%03d"):format(id) + local layout, g4 = layouts[z.matrix], gen4Matrices[z.matrix] + headers[id] = { id = id, internalName = mapId, matrix = z.matrix, area = z.area, + scripts = z.scripts, initScripts = z.initScripts, messages = z.messages, + encounters = z.encounters, events = z.events, family = "bw" } + if layout and g4 then + local grid, region = layout, nil + if g4.headers then + extentsByMatrix[z.matrix] = extentsByMatrix[z.matrix] or Gen4Maps.extents(g4) or {} + region = extentsByMatrix[z.matrix][id] + local cut = region and Gen4Maps.crop(layout, region.x, region.y, region.width, region.height) + if cut then grid = cut end + end + maps[mapId] = { + id = mapId, header = id, layout = z.matrix, + label = names and names[z.nameIndex] or mapId, + width = grid.width, height = grid.height, + blocks = grid.blocks, behaviorCells = grid.behaviorCells, + borderBlock = grid.borderBlock, blockPx = 16, generation = 4, gen5 = true, + tileset = require("src.import.Gen4Tileset").ID, + originX = grid.originX or (region and region.x) or 0, + originY = grid.originY or (region and region.y) or 0, + encounters = z.encounters, events = z.events, messages = z.messages, + music = z.music, objects = {}, warps = {}, signs = {}, coordEvents = {}, + gen5Camera = cameras[z.camera or 0] or cameras[0], + gen5Rail = rails[id] and { file = rails[id].file, cameraArea = rails[id].cameraArea } or nil, + gen5NoGrid = ngScale[z.matrix] and true or nil, + } + -- the heights, as the per-tile override Gen4Ground:heightsAt reads first + local def, edits, n = maps[mapId], {}, 0 + for ly = 0, def.height - 1 do + for lx = 0, def.width - 1 do + local mx, my = def.originX + lx, def.originY + ly + local cx, cy = math.floor(mx / F.CHUNK), math.floor(my / F.CHUNK) + local cid = cx < g4.width and cy < g4.height and g4.maps[cy * g4.width + cx + 1] + local hs = cid and heightsOf[cid] + local h = hs and hs[(my % F.CHUNK) * F.CHUNK + (mx % F.CHUNK) + 1] + if h then edits[lx .. "," .. ly] = h; n = n + 1 end + end + end + if n > 0 then def.gen4HeightEdits = edits end + local outdoors = g4.headers ~= nil + local kind, pack = F.packFor(z.area) + terrainMaps[mapId] = { texture = z.area, area = z.area, areaLight = 0, outdoors = outdoors, + buildingKind = kind, buildingPack = pack } + end + end + + -- the people, and every zone's events on its map + local sprites, spriteTable, spriteCount = F.characters(rom, save) + local function spriteFor(id) + local member = spriteTable[id] + local key = member and ("SPRITE_G4_%03d"):format(member) + return (key and sprites[key]) and key or nil, member and ("bw_%03d"):format(member) or nil + end + local Events = require("src.import.Gen5Events") + local evArc = arc(F.PATH.events) + local npcs, placed = 0, 0 + for mapId, def in pairs(maps) do + local z = zones[def.header] + local ev = z and evArc and Events.parse(evArc:get(z.events)) + if ev then + Events.attach(def, ev, spriteFor) + for _, o in ipairs(def.objects) do npcs = npcs + 1; if o.sprite then placed = placed + 1 end end + end + end + report.sprites, report.npcs, report.npcsWithSprite = spriteCount, npcs, placed + + -- ONE MAP, SEVERAL TEXTURE SETS. A zone cut out of a shared matrix keeps + -- the bounding box of its cells, and the box takes in its neighbours' chunks + -- -- Route 1's holds Accumula's gate, whose shapes wear `out38_*` and + -- `bgate02_*` from Accumula's set, not Route 1's. Gen4Ground draws a map + -- with one set, so such a map gets a MERGED set: every zone's set whose cells + -- lie in the box, the map's own first so a shared name keeps its own picture. + local merged, mergedCount = {}, 0 + for mapId, rec in pairs(terrainMaps) do + local def = maps[mapId] + local g4 = def and gen4Matrices[def.layout] + if g4 and g4.headers then + local x0, y0 = math.floor(def.originX / F.CHUNK), math.floor(def.originY / F.CHUNK) + local x1 = math.floor((def.originX + def.width - 1) / F.CHUNK) + local y1 = math.floor((def.originY + def.height - 1) / F.CHUNK) + local areas, order = {}, { rec.texture } + areas[rec.texture] = true + -- one chunk of margin: the camera sees past the box's edge + for cy = math.max(0, y0 - 1), math.min(g4.height - 1, y1 + 1) do + for cx = math.max(0, x0 - 1), math.min(g4.width - 1, x1 + 1) do + local zid = g4.headers[cy * g4.width + cx + 1] + local z = zid and zones[zid] + if z and not areas[z.area] then areas[z.area] = true; order[#order + 1] = z.area end + end + end + if #order > 1 then + local key = 10000 + def.header + local entry = { textures = {}, merged = order } + for _, area in ipairs(order) do + for name, tex in pairs((sets[area] or {}).textures or {}) do + if entry.textures[name] == nil then entry.textures[name] = tex end + end + end + merged[key] = entry + rec.texture = key + mergedCount = mergedCount + 1 + end + end + end + for k, v in pairs(merged) do sets[k] = v end + report.mergedSets = mergedCount + + local terrain = { + source = "ROM:" .. tostring(x.version) .. " (/a/0/0/8 + /a/0/0/9 + /a/0/1/4)", + chunkTiles = Terrain.CHUNK_TILES, tileUnits = Terrain.TILE_UNITS, + chunkUnits = Terrain.CHUNK_UNITS, pixelsPerUnit = Terrain.PIXELS_PER_UNIT, + chunks = terrainChunks, matrices = grids, maps = terrainMaps, sets = sets, + refused = refused, + } + terrain.chunkFile = save.binary("terrain/chunks.bin", geometry) + terrain.heightFile = save.binary("terrain/heights.bin", heights) + terrain.permissionFile = save.binary("terrain/permissions.bin", perms) + terrain.chunkBytes, terrain.heightBytes, terrain.permissionBytes = #geometry, #heights, #perms + + local outdoorPacks, outModels, outShapes = F.buildingPacks(rom, "outdoor", save) + local indoorPacks, inModels, inShapes = F.buildingPacks(rom, "indoor", save) + report.buildingModels, report.buildingShapes = outModels + inModels, outShapes + inShapes + + -- how many placements find their building in the pack their map names + local found, missing = 0, 0 + for mapId, rec in pairs(terrainMaps) do + local def, packs = maps[mapId], rec.buildingKind == "outdoor" and outdoorPacks or indoorPacks + local set = packs[rec.buildingPack] + local grid = def and grids[def.layout] + if grid and def then + local x0, y0 = math.floor(def.originX / F.CHUNK), math.floor(def.originY / F.CHUNK) + local x1 = math.floor((def.originX + def.width - 1) / F.CHUNK) + local y1 = math.floor((def.originY + def.height - 1) / F.CHUNK) + for cy = y0, math.min(y1, grid.height - 1) do + for cx = x0, math.min(x1, grid.width - 1) do + local entry = terrainChunks[grid.land[cy * grid.width + cx + 1]] + for _, o in ipairs(entry and entry.objects or {}) do + if set and set.models[o.model + 1] then found = found + 1 else + missing = missing + 1 + report.missingByKind = report.missingByKind or {} + report.missingByKind[rec.buildingKind] = (report.missingByKind[rec.buildingKind] or 0) + 1 + end + end + end + end + end + end + report.placementsFound, report.placementsMissing = found, missing + + local count = function(t) local n = 0 for _ in pairs(t) do n = n + 1 end return n end + report.zones, report.maps, report.chunks = count(zones), count(maps), count(terrainChunks) + report.refused, report.sets, report.layouts = count(refused), setsBuilt, count(layouts) + -- THE SEAMS: zones cut from one matrix that touch edge to edge are joined + -- the way Gen 4's are (RomExtractorGen4.linkRegions: `connections` keyed + -- north/south/west/east, offset = the difference of their origins), so + -- Route 1's north edge hands the player to Accumula without a warp. + -- A RAIL MAP'S GRID. NG chunks carry no permissions, and their models (and + -- the rails on them) reach two or three chunks past the matrix's own few + -- cells -- Skyarrow Bridge's points run to z 4,641 on a 3-chunk (1,536) + -- matrix. So the grid is sized to the rails, every cell blocked, and each + -- rail line is RASTERISED in as walkable ground: a straight run between its + -- points, or an arc about its curve record's centre (type 1), as wide as + -- the points' widths for that line (taken as half-widths in world units -- + -- Castelia's ring is 144, the bridge 16). An approximation of BW's rail + -- walking on the engine's grid: the corridor is right, the analogue + -- movement along it is not modelled. + for id, def in pairs(maps) do + local rail = def.gen5NoGrid and rails[def.header] + if def.gen5NoGrid then + local data = rail and rail.data + local maxX, maxZ = def.width * 16, def.height * 16 + for _, pt in pairs(data and data.points or {}) do + if pt.x + 256 > maxX then maxX = pt.x + 256 end + if pt.z + 256 > maxZ then maxZ = pt.z + 256 end + end + local w, h = math.ceil(maxX / 16), math.ceil(maxZ / 16) + local open, heights = {}, {} + local function mark(x, z, half, y) + local r = math.max(half, 8) + for ty = math.floor((z - r) / 16), math.floor((z + r) / 16) do + for tx = math.floor((x - r) / 16), math.floor((x + r) / 16) do + if tx >= 0 and ty >= 0 and tx < w and ty < h then + local cx, cz = tx * 16 + 8, ty * 16 + 8 + if (cx - x) ^ 2 + (cz - z) ^ 2 <= r * r then + open[ty * w + tx] = true + -- the rail's own height: Skyarrow's deck is 223 units up + if y and y ~= 0 then heights[tx .. "," .. ty] = math.floor(y + 0.5) end + end + end + end + end + end + local function widthAt(pt, line) + for k = 1, 4 do if pt.lines[k] == line then return pt.widths[k] end end + return 16 + end + for li, line in pairs(data and data.lines or {}) do + local a, b = data.points[line.a], data.points[line.b] + if a and b then + local half = math.max(widthAt(a, li), widthAt(b, li)) + local curve = data.curves[li] + if curve and curve.kind == 1 then + local ra = math.sqrt((a.x - curve.cx) ^ 2 + (a.z - curve.cz) ^ 2) + local rb = math.sqrt((b.x - curve.cx) ^ 2 + (b.z - curve.cz) ^ 2) + local ta = math.atan2(a.z - curve.cz, a.x - curve.cx) + local tb = math.atan2(b.z - curve.cz, b.x - curve.cx) + local d = tb - ta + if d > math.pi then d = d - 2 * math.pi elseif d < -math.pi then d = d + 2 * math.pi end + local n = math.max(8, math.ceil(math.abs(d) * math.max(ra, rb) / 4)) + for i = 0, n do + local t = i / n + local r, th = ra + (rb - ra) * t, ta + d * t + mark(curve.cx + r * math.cos(th), curve.cz + r * math.sin(th), half, a.y + (b.y - a.y) * t) + end + else + local len = math.sqrt((b.x - a.x) ^ 2 + (b.z - a.z) ^ 2) + local n = math.max(1, math.ceil(len / 4)) + for i = 0, n do + local t = i / n + mark(a.x + (b.x - a.x) * t, a.z + (b.z - a.z) * t, half, a.y + (b.y - a.y) * t) + end + end + end + end + local blocks, behav = {}, {} + for i = 0, w * h - 1 do + if open[i] then blocks[#blocks + 1] = "\0\0"; behav[#behav + 1] = "\0" + else blocks[#blocks + 1] = "\255\0"; behav[#behav + 1] = "\255" end + end + def.width, def.height = w, h + def.blocks, def.behaviorCells = table.concat(blocks), table.concat(behav) + def.gen4HeightEdits = next(heights) and heights or nil + end + end + local linker = { } + require("src.import.RomExtractorGen4").linkRegions(linker, maps) + report.seams = linker.regionLinkReport + local railFiles = {} + for _, r in pairs(rails) do railFiles[r.file] = r.data end + report.rails = 0 for _ in pairs(rails) do report.rails = report.rails + 1 end + return { + rails = railFiles, + maps = maps, headers = headers, layouts = layouts, matrices = gen4Matrices, + permissions = permissions, terrain = terrain, zones = zones, + sprites = sprites, spriteTable = spriteTable, + models = { source = "ROM:" .. tostring(x.version) .. " (/a/2/2/9, /a/2/3/0 + /a/1/7/6, /a/1/7/7)", + sets = { bwOutdoor = { packs = outdoorPacks }, bwIndoor = { packs = indoorPacks } } }, + }, report +end + +return F diff --git a/src/import/Gen5Font.lua b/src/import/Gen5Font.lua new file mode 100644 index 000000000..70f76b3cd --- /dev/null +++ b/src/import/Gen5Font.lua @@ -0,0 +1,132 @@ +-- BLACK AND WHITE'S FONTS: Nitro NFTR ("RTFN" on disk), /a/0/2/3. +-- +-- Gen 4's fonts are Game Freak's own NFGR; Gen 5 moved to the SDK's standard +-- font format, whose layout is public (the NitroSystem font file): +-- +-- header "RTFN", u16 BOM, u16 version, u32 size, u16 header size, u16 blocks +-- FINF u32 size, u8 type, u8 line feed, u16 alt glyph, 3 bytes default +-- widths, u8 encoding, u32 CGLP, u32 CWDH, u32 CMAP (each pointing +-- 8 bytes into its block) +-- CGLP u32 size, u8 cell width, u8 cell height, u16 bytes per glyph, +-- u8 baseline, u8 max width, u8 bpp, u8 rotation, then the glyphs +-- (row-major, most significant bits first) +-- CWDH u32 size, u16 first, u16 last, u32 next, then { s8 left, u8 glyph +-- width, u8 advance } per glyph +-- CMAP u32 size, u16 first code, u16 last code, u16 type, u16 pad, u32 next, +-- then type 0: u16 first glyph (code - first + it); type 1: u16 per +-- code (0xFFFF none); type 2: u16 count, { u16 code, u16 glyph } pairs +-- +-- A glyph's 2-bit pixels are the same four roles Gen4Font colours (none, text, +-- shadow, background), so the sheet is drawn by Gen4Font.sheet unchanged. + +local F = {} + +local function u8(s, o) return s:byte(o + 1) or 0 end +local function u16(s, o) return u8(s, o) + u8(s, o + 1) * 256 end +local function u32(s, o) return u16(s, o) + u16(s, o + 2) * 65536 end +local function s8(s, o) local v = u8(s, o); return v >= 128 and v - 256 or v end + +function F.parse(data) + if type(data) ~= "string" or data:sub(1, 4) ~= "RTFN" then return nil, "not an NFTR font" end + local finf = u16(data, 12) + if data:sub(finf + 1, finf + 4) ~= "FNIF" then return nil, "no FINF block" end + local cglp, cwdh, cmap = u32(data, finf + 0x10), u32(data, finf + 0x14), u32(data, finf + 0x18) + -- each offset points 8 bytes into its block (past magic and size) + local g = cglp + local cellW, cellH, glyphBytes = u8(data, g), u8(data, g + 1), u16(data, g + 2) + local bpp = u8(data, g + 6) + local blockSize = u32(data, g - 4) + local count = math.floor((blockSize - 16) / glyphBytes) + local font = { pixelWidth = cellW, pixelHeight = cellH, bpp = bpp, glyphBytes = glyphBytes, + numGlyphs = count, glyphAt = g + 8, data = data, widths = {}, lefts = {}, advances = {}, + codes = {} } + -- widths: possibly several CWDH blocks chained by `next` + local at = cwdh + while at and at > 0 and at < #data do + local first, last, nextAt = u16(data, at), u16(data, at + 2), u32(data, at + 4) + for i = first, last do + local o = at + 8 + (i - first) * 3 + font.lefts[i], font.widths[i], font.advances[i] = s8(data, o), u8(data, o + 1), u8(data, o + 2) + end + at = nextAt ~= 0 and nextAt or nil + end + -- character map, chained the same way + at = cmap + local guard = 0 + while at and at > 0 and at < #data and guard < 512 do + guard = guard + 1 + local first, last, kind, nextAt = u16(data, at), u16(data, at + 2), u16(data, at + 4), u32(data, at + 8) + local body = at + 12 + if kind == 0 then + local base = u16(data, body) + for c = first, last do font.codes[c] = base + (c - first) end + elseif kind == 1 then + for c = first, last do + local gi = u16(data, body + (c - first) * 2) + if gi ~= 0xFFFF then font.codes[c] = gi end + end + elseif kind == 2 then + local n = u16(data, body) + for i = 0, n - 1 do font.codes[u16(data, body + 2 + i * 4)] = u16(data, body + 4 + i * 4) end + end + at = nextAt ~= 0 and nextAt or nil + end + font.maxWidth = cellW + -- EACH GLYPH SLOT OPENS WITH ITS OWN WIDTH RECORD. The slot is 48 bytes and + -- a 12 x 15 bitmap at 2 bpp is 45: the first three are { s8 left, u8 glyph + -- width, u8 advance } -- every glyph, the space included, carries them -- + -- and the bitmap follows. (Read as bitmap they are the stray top row of + -- dots every line of text showed; "T" is 0, 6, 6 and the space 4, 0, 4.) + font.recordBytes = glyphBytes - math.ceil(cellW * cellH * bpp / 8) + if font.recordBytes < 0 then font.recordBytes = 0 end + if font.recordBytes >= 3 then + for i = 0, count - 1 do + local base = font.glyphAt + i * glyphBytes + font.lefts[i] = s8(data, base) + font.widths[i] = u8(data, base + 2) + end + end + return font +end + +-- glyph(font, index) -> { role per pixel, row-major } +function F.glyph(font, index) + if index < 0 or index >= font.numGlyphs then return nil end + local base = font.glyphAt + index * font.glyphBytes + (font.recordBytes or 0) + local out, bits = {}, font.bpp + local per = math.floor(8 / bits) + for p = 0, font.pixelWidth * font.pixelHeight - 1 do + local byte = u8(font.data, base + math.floor(p / per)) + local shift = 8 - bits * (p % per + 1) + out[p + 1] = math.floor(byte / 2 ^ shift) % (2 ^ bits) + end + return out +end + +-- sheet(font) -> image through Gen4Font.sheet's colouring +function F.sheet(font, options) + local Gen4Font = require("src.import.Gen4Font") + local saved = Gen4Font.glyph + Gen4Font.glyph = function(_, index) return F.glyph(font, index) end + local ok, sheet = pcall(Gen4Font.sheet, font, options) + Gen4Font.glyph = saved + return ok and sheet or nil +end + +local function utf8(cp) + if cp < 0x80 then return string.char(cp) end + if cp < 0x800 then return string.char(0xC0 + math.floor(cp / 64), 0x80 + cp % 64) end + return string.char(0xE0 + math.floor(cp / 4096), 0x80 + math.floor(cp / 64) % 64, 0x80 + cp % 64) +end + +-- charmap(font, base) -> { { code = glyph + base, seq = UTF-8 } }, sorted +function F.charmap(font, base) + local out = {} + for cp, gi in pairs(font.codes) do + if cp >= 0x20 and gi < font.numGlyphs then out[#out + 1] = { code = gi + (base or 1), seq = utf8(cp) } end + end + table.sort(out, function(a, b) return a.code < b.code end) + return out +end + +return F diff --git a/src/import/Gen5Lz.lua b/src/import/Gen5Lz.lua new file mode 100644 index 000000000..65c5e30f4 --- /dev/null +++ b/src/import/Gen5Lz.lua @@ -0,0 +1,78 @@ +-- NINTENDO LZ77, BOTH VARIANTS (Black and White mostly use 0x11). +-- +-- Gen 4's graphics are LZ10 (Gen4Graphics.decompress); Gen 5's sprites are +-- LZ11 -- the same flag-byte scheme with longer back-references: +-- +-- header: u8 type (0x10 / 0x11), u24 decompressed size (0 => a u32 follows) +-- then groups of one flag byte (most significant bit first) and eight tokens: +-- 0 -> one literal byte +-- 1 -> a back-reference. LZ10: 2 bytes, length (b0 >> 4) + 3, +-- distance ((b0 & 0xF) << 8 | b1) + 1. +-- LZ11: the top nibble of the first byte picks the form -- +-- 0: 3 bytes, length ((b0 & 0xF) << 4 | b1 >> 4) + 0x11 +-- 1: 4 bytes, length ((b0 & 0xF) << 12 | b1 << 4 | b2 >> 4) + 0x111 +-- 2+: 2 bytes, length (b0 >> 4) + 1 +-- and the distance is the last 12 bits + 1 in every form. + +local Lz = {} + +function Lz.isCompressed(data) + if type(data) ~= "string" or #data < 4 then return false end + local t = data:byte(1) + return t == 0x10 or t == 0x11 +end + +function Lz.decompress(data) + if not Lz.isCompressed(data) then return data end + local kind = data:byte(1) + local size = data:byte(2) + data:byte(3) * 256 + data:byte(4) * 65536 + local p = 5 + if size == 0 and #data >= 8 then + size = data:byte(5) + data:byte(6) * 256 + data:byte(7) * 65536 + data:byte(8) * 16777216 + p = 9 + end + local out, n = {}, 0 + local function byte() local b = data:byte(p); p = p + 1; return b end + while n < size and p <= #data do + local flags = byte() + for bit = 7, 0, -1 do + if n >= size or p > #data then break end + if math.floor(flags / 2 ^ bit) % 2 == 0 then + n = n + 1; out[n] = byte() + else + local length, dist + local b0 = byte() + if kind == 0x10 then + local b1 = byte() + length = math.floor(b0 / 16) + 3 + dist = (b0 % 16) * 256 + b1 + 1 + else + local hi = math.floor(b0 / 16) + if hi == 0 then + local b1, b2 = byte(), byte() + length = ((b0 % 16) * 16 + math.floor(b1 / 16)) + 0x11 + dist = (b1 % 16) * 256 + b2 + 1 + elseif hi == 1 then + local b1, b2, b3 = byte(), byte(), byte() + length = ((b0 % 16) * 4096 + b1 * 16 + math.floor(b2 / 16)) + 0x111 + dist = (b2 % 16) * 256 + b3 + 1 + else + local b1 = byte() + length = hi + 1 + dist = (b0 % 16) * 256 + b1 + 1 + end + end + for _ = 1, length do + n = n + 1 + out[n] = out[n - dist] or 0 + if n >= size then break end + end + end + end + end + local parts = {} + for i = 1, n, 4096 do parts[#parts + 1] = string.char(unpack(out, i, math.min(i + 4095, n))) end + return table.concat(parts) +end + +return Lz diff --git a/src/import/Gen5Maps.lua b/src/import/Gen5Maps.lua new file mode 100644 index 000000000..3a25915cb --- /dev/null +++ b/src/import/Gen5Maps.lua @@ -0,0 +1,114 @@ +-- BLACK AND WHITE'S FIELD: zones, matrices and map chunks. +-- +-- pokeblack has not decompiled the field, so every layout here was IDENTIFIED +-- IN THE CARTRIDGE and is checked against it by tools/gen5_import_check.lua: +-- +-- ZONES, /a/0/1/2 (one file, 427 x 48 bytes) -- each field's range was +-- measured against the archive it indexes: +-- 00 u8 map type 01 u8 (flags) +-- 02 u16 area -- < 282: /a/0/1/4 texture sets, /a/1/0/8 +-- 04 u16 matrix -- < 255: /a/0/0/9 +-- 06 u16 scripts 08 u16 init scripts -- /a/0/5/7, always a pair +-- 0A u16 story text bank -- < 472: /a/0/0/3 +-- 0C..12 u16 music: spring, summer, autumn, winter +-- 14 u16 wild encounters -- /a/1/2/6, 0xFFFF none +-- 16 u16 the zone's own id -- its events, /a/1/2/5 +-- 18 u16 parent zone +-- 1A u8 place name -- system bank 89 ("Striaton City" for 6..15) +-- 20 u16 the matrix again (the town map's) +-- +-- MATRICES, /a/0/0/9: u32 hasZones, u16 width, u16 height, then width*height +-- u32 chunk ids (0xFFFFFFFF none) and, when hasZones, as many u32 zone ids. +-- Matrix 0 is Unova, 29 x 27: 8 + 29*27*4*2 = 6,272 bytes, the member's size. +-- +-- CHUNKS, /a/0/0/8 (649): a two-letter magic, a u16 section count and the +-- section offsets (u32 each, then the end). 572 are "WB" with three sections +-- -- the NSBMD model, the permissions (u16 width 32, u16 height 32, then +-- 8 bytes a tile), the building placements (u32 count, 16 bytes each) -- and +-- the rest are "GC" (4 sections), "NG" (2) and "RD" (3). The model is always +-- the first section. + +local M = {} + +M.ZONE_BYTES = 48 +M.NONE = 0xFFFFFFFF + +local function u8(s, o) return s:byte(o + 1) or 0 end +local function u16(s, o) return u8(s, o) + u8(s, o + 1) * 256 end +local function u32(s, o) return u16(s, o) + u16(s, o + 2) * 65536 end + +function M.zones(bytes) + local out = {} + if type(bytes) ~= "string" then return out end + for i = 0, math.floor(#bytes / M.ZONE_BYTES) - 1 do + local o = i * M.ZONE_BYTES + local wild = u16(bytes, o + 0x14) + out[i] = { + id = i, mapType = u8(bytes, o), flags = u8(bytes, o + 1), + area = u16(bytes, o + 2), matrix = u16(bytes, o + 4), + scripts = u16(bytes, o + 6), initScripts = u16(bytes, o + 8), + messages = u16(bytes, o + 0x0A), + music = { u16(bytes, o + 0x0C), u16(bytes, o + 0x0E), u16(bytes, o + 0x10), u16(bytes, o + 0x12) }, + encounters = wild ~= 0xFFFF and wild or nil, + events = u16(bytes, o + 0x16), parent = u16(bytes, o + 0x18), + nameIndex = u8(bytes, o + 0x1A), + townMapMatrix = u16(bytes, o + 0x20), + -- bits 9..15 of +0x1C: the camera, a record of /a/0/6/0 (the field + -- camera set-up at 0x0218E018 reads it through 0x02013BBC) + camera = math.floor(u16(bytes, o + 0x1C) / 512) % 128, + -- THE ZONE'S DEFAULT STANDING SPOT: +0x24 x, +0x28 y, +0x2C z, u32 each. + -- The new-game location setter (ARM9 0x2014204, called with zone 391, + -- the player's room) copies these three into the start location when + -- the zone has them (0x2013b90 reads them off this record). + spawnX = u32(bytes, o + 0x24), spawnY = u32(bytes, o + 0x28), spawnZ = u32(bytes, o + 0x2C), + } + end + return out +end + +function M.matrix(bytes) + if type(bytes) ~= "string" or #bytes < 8 then return nil end + local hasZones, w, h = u32(bytes, 0), u16(bytes, 4), u16(bytes, 6) + local cells = w * h + if #bytes < 8 + cells * 4 * (hasZones ~= 0 and 2 or 1) then return nil end + local chunks, zones = {}, nil + for i = 0, cells - 1 do + local v = u32(bytes, 8 + i * 4) + chunks[i + 1] = v ~= M.NONE and v or nil + end + if hasZones ~= 0 then + zones = {} + for i = 0, cells - 1 do + local v = u32(bytes, 8 + cells * 4 + i * 4) + zones[i + 1] = v ~= M.NONE and v or nil + end + end + return { width = w, height = h, chunks = chunks, zones = zones } +end + +-- chunk(bytes) -> { kind, model, permissions (raw), buildings (raw) } +function M.chunk(bytes) + if type(bytes) ~= "string" or #bytes < 8 then return nil, "too short" end + local kind, n = bytes:sub(1, 2), u16(bytes, 2) + if n < 1 or n > 8 then return nil, "no section table" end + local offs = {} + for i = 0, n do offs[i + 1] = u32(bytes, 4 + i * 4) end + offs[n + 1] = offs[n + 1] ~= 0 and offs[n + 1] or #bytes + local sections = {} + for i = 1, n do + local a, e = offs[i], offs[i + 1] + if a > #bytes or e > #bytes or e < a then return nil, "section out of range" end + sections[i] = bytes:sub(a + 1, e) + end + local out = { kind = kind, sections = sections, model = sections[1] } + if kind == "WB" then out.permissions, out.buildings = sections[2], sections[3] end + -- "GC" chunks (the Dreamyard's four, 279..282, and others with a second + -- walking layer) carry FOUR sections: the model, the permissions in WB's + -- 4 + 1024 x 8 bytes, a second 8,204-byte layer (not read yet), and the + -- buildings (u32 count + 16 each). Read as WB, every tile fell back to + -- "blocked" and the player could not take a step. + if kind == "GC" then out.permissions, out.layer2, out.buildings = sections[2], sections[3], sections[4] end + return out +end + +return M diff --git a/src/import/Gen5Overworld.lua b/src/import/Gen5Overworld.lua new file mode 100644 index 000000000..e8d503578 --- /dev/null +++ b/src/import/Gen5Overworld.lua @@ -0,0 +1,50 @@ +-- BLACK AND WHITE'S OVERWORLD CHARACTERS. +-- +-- A map's NPC names a sprite id (Gen5Events); the sprite table /a/0/4/8 -- +-- one file, a u32 count (799) then 28 bytes per id -- turns the id into a +-- MODEL: its ninth u16 runs 0..763 over exactly 764 distinct values, the +-- member count of /a/0/4/9, an archive of 764 NSBMD files. Gen 5 draws its +-- characters as textured billboards and keeps the frames in each model's own +-- TEX0 -- where Gen 4's mmodel.narc is bare BTX0 texture files. +-- +-- So each model's TEX0 is wrapped in the standard 20-byte BTX0 container (the +-- section is the same bytes either way) and handed to Gen4Models.sheet, which +-- already turns a texture file of equal-sized frames into a sheet. + +local Nsbmd = require("src.import.Gen4Nsbmd") + +local O = {} + +O.TABLE = "/a/0/4/8" +O.MODELS = "/a/0/4/9" +O.RECORD_BYTES = 28 + +local function u16(s, o) return s:byte(o + 1) + s:byte(o + 2) * 256 end +local function u32le(v) return string.char(v % 256, math.floor(v / 256) % 256, math.floor(v / 65536) % 256, math.floor(v / 16777216) % 256) end + +-- table(bytes) -> { [spriteId] = modelMember } +function O.table(bytes) + local out = {} + if type(bytes) ~= "string" or #bytes < 4 then return out end + local n = u16(bytes, 0) + for i = 0, n - 1 do + local o = 4 + i * O.RECORD_BYTES + if o + O.RECORD_BYTES > #bytes then break end + out[u16(bytes, o)] = u16(bytes, o + 16) + end + return out +end + +-- btx(nsbmdBytes) -> a BTX0 file holding the model's TEX0, or nil +function O.btx(bytes) + local sections = bytes and Nsbmd.sections(bytes) + local at = sections and sections.TEX0 + if not at then return nil end + local size = bytes:byte(at + 5) + bytes:byte(at + 6) * 256 + bytes:byte(at + 7) * 65536 + bytes:byte(at + 8) * 16777216 + local tex = bytes:sub(at + 1, at + size) + local total = 20 + #tex + return "BTX0" .. string.char(0xFF, 0xFE, 0x01, 0x00) .. u32le(total) + .. string.char(0x10, 0x00, 0x01, 0x00) .. u32le(20) .. tex +end + +return O diff --git a/src/import/Gen5ScriptLink.lua b/src/import/Gen5ScriptLink.lua new file mode 100644 index 000000000..a15decd67 --- /dev/null +++ b/src/import/Gen5ScriptLink.lua @@ -0,0 +1,786 @@ +-- BLACK AND WHITE'S SCRIPTS, IN THE POOL THE GEN 4 SCRIPT ENGINE RUNS. +-- +-- Gen5ScriptOps knows how wide every command is; this names the commands whose +-- meaning the cartridge's own scripts make plain, in Platinum's words, so +-- Gen4ScriptVM lowers them unchanged -- the same move HGSS's aliases make. +-- +-- WHAT IDENTIFIES EACH. A Striaton conversation decodes as 2E, A6 0547, 74, +-- 3D 00 04 0009 0000 0000, 32, 3E, 30, 2F, 02 -- the Gen 4 order of lock all, +-- play sound, face player, message, wait for a press, close, (pause), release +-- all, end -- and entry 9 of the zone's story bank is that NPC's line. The +-- control flow is fixed by the widths and the jump targets themselves (Call +-- and Jump a u32 that lands on an instruction, Return and End nothing). The +-- rest: +-- +-- 0x23 / 0x24 a flag id (u16) set / clear flag +-- 0x28 var (0x8000+), value set var to value +-- 0x3C like 0x3D with the message id at operand 3 and two more +-- 0x64 / 0x65 object + u32 to a movement list / no operands +-- apply / wait movement. All 4,129 lists end at 0xFE in Gen 4's +-- { u16 action, u16 count } form, from the byte after the +-- 8-byte instruction, and the object ids are Gen 4's (0xFF the +-- player at 1,060 sites, else the NPC's local id), so +-- Gen4Movement decodes them unchanged +-- +-- BW's conditionals are STACK-based (push a constant / a var / a flag, +-- compare, then 0x1F jumps on the result); `rewriteStack` turns each one into +-- Platinum's compare + gotoif chains (see THE STACK FORM below). + +local Ops = require("src.import.Gen5ScriptOps") +local Movement = require("src.import.Gen4Movement") + +local L = {} + +-- { name, spec, order }: spec splits the operands (b = u8, w = u16, d = u32); +-- order picks which become the Platinum command's arguments +L.ALIASES = { + [0x02] = { "end", "" }, [0x05] = { "return", "" }, + [0x04] = { "call", "d" }, [0x1E] = { "goto", "d" }, + [0x2E] = { "lockall", "" }, [0x2F] = { "releaseall", "" }, [0x30] = { "noop", "" }, + [0x74] = { "faceplayer", "" }, + [0x32] = { "waitabpress", "" }, [0x3E] = { "closemessage", "" }, [0x3F] = { "closemessage", "" }, + [0x3D] = { "message", "bbwww", { 3 } }, [0x3C] = { "message", "bbwwww", { 3 } }, + [0xA6] = { "playse", "w" }, + [0x23] = { "setflag", "w" }, [0x24] = { "clearflag", "w" }, + -- 0x25 : the flag's state into a var (the handler reads the + -- flag through the same event-flag routine 0x23 / 0x24 write it with); + -- 0x06 reads a value and does nothing with it + [0x25] = { "bwflagtovar", "ww" }, [0x06] = { "noop", "w" }, + [0x28] = { "setvarfromvalue", "ww" }, + [0x64] = { "applymovement", "wd" }, [0x65] = { "waitmovement", "" }, + -- read off their handlers (overlay 10): 0x09 pushes a var's value, 0x0A + -- pops into a var, 0x2A is `var = var-or-value`, 0x1C runs the common + -- script and waits on it (one u16, see Gen5ScriptOps) + -- 0x29 copies var to var, 0x26 / 0x27 add / subtract a var-or-value, 0x03 + -- stores its u16 in the context's counter and pauses until it reaches 0 + [0x2A] = { "setvar", "ww" }, [0x29] = { "setvarfromvar", "ww" }, + [0x26] = { "addvar", "ww" }, [0x27] = { "subvar", "ww" }, [0x03] = { "waittime", "w" }, + -- from the scripts around them, with the handlers agreeing on the operands: + -- 0x6C removes it (run in batches just before their hide flags) + -- 0x6D places it -- Platinum's setposition, + -- the same five var-or-values in the same order + -- 0x4C the player's name into a string slot, before a message + -- 0x34 / 0x36 a message box and its close + -- (`34 0027, 32, 36, releaseall`); the id may be a var (0x804D) + -- 0x47 the yes/no box after a question, 0 for YES as in + -- Platinum: Z054's masseuse runs `if (var == 0) { massage }` and + -- jumps past it to "Oh, I see. Please see me if you change your mind." + [0x6C] = { "removeobject", "w" }, [0x6D] = { "setposition", "wwwww" }, + [0x4C] = { "bufferplayername", "b" }, + [0x34] = { "message", "ww", { 1 } }, [0x36] = { "closemessage", "" }, + [0x47] = { "showyesnomenu", "w" }, + -- 0xA9 / 0xAA a jingle and the wait for it (the masseuse plays + -- one between "Let me get started" and "All done") + -- 0xB4 waits for the 0xB3 fade (see rewriteCompound) + -- 0xCB var = random below bound: the handler calls the + -- DS's 64-bit LCG (0x020056EC), which scales its high word by bound + [0xA9] = { "playfanfare", "w" }, [0xAA] = { "waitfanfare", "" }, + [0xB4] = { "waitfadescreen", "" }, [0xCB] = { "getrandom", "ww" }, + -- 0x43 / 0x44 a sign-style box: the handler prints and + -- pauses until it is dismissed (`2E A6 43 44 30 2F 02` is a whole + -- talk), so it is Platinum's message + waitabpress; 0x44 closes it + -- 0xA8 waits for the sound just played (`A6 069D, A8, A6 069B, A8`) + [0x43] = { "message", "ww", { 1 } }, [0x44] = { "closemessage", "" }, + [0xA8] = { "waitse", "" }, + -- 0x85 a trainer battle (the trainer may + -- be a var: `85 0x8022 0 0`). Then `8D var; if (var == 1) { 8E } + -- else { 8C }`: 0x8D writes 1 when the battle was WON, 0x8E is the + -- return to the field and 0x8C the black-out. The bedroom battle + -- settles it: Bianca's may be lost, so it runs 0x8E unconditionally + -- and then `8D var; if (var == 1)` says "you're gonna be an awesome + -- Trainer" (bank 430 #37) and otherwise "I won before even + -- realizing" (#38). (An earlier reading had all three backwards, + -- which the gym's own shape cannot tell apart.) + -- 0xD5 var = bit `badge` of the save's badge mask (the gym + -- asks for badge 0 before its leader's challenge) + [0x85] = { "starttrainerbattle", "ww" }, + [0x8E] = { "returntofield", "" }, [0x8C] = { "blackoutfrombattle", "" }, + -- 0xD6 sets that bit of the badge mask (and stamps the time); + -- 0xE1 counts the mask's bits into var + [0xD5] = { "checkbadgeacquired", "ww", { 2, 1 } }, + [0xD6] = { "givebadge", "w" }, [0xE1] = { "countbadgesacquired", "w" }, + -- 0x38 / 0x4A two more message boxes (0x38's + -- handler is a tail-jump stub, see Gen5ScriptOps); 0x39 closes them + -- (`38 0025 02, 32, 39`) and 0x4B waits on the box like 0x32 + -- 0x6E the player's facing, tested for 0..3 straight after + [0x38] = { "message", "wb", { 1 } }, [0x4A] = { "message", "wb", { 1 } }, + [0x39] = { "closemessage", "" }, [0x4B] = { "waitabpress", "" }, + [0x6E] = { "getplayerdir", "w" }, + -- THE CUTSCENE CAMERA runs on Platinum's: `addfreecamera` stands an + -- invisible body the camera follows, so a scene looks where BW's does + -- under whichever CAM TILT the player picked (cartridge, an angle, first + -- or third person). The sequence is + -- 0x13F, 0x141 (start, lock) 0x143 (move, see rewriteCompound) + -- 0x145 (wait) 0x144 / 0x147 (back to the player) + -- 0x142, 0x140 (unlock, end) + [0x13F] = { "noop", "" }, [0x141] = { "noop", "" }, [0x142] = { "noop", "" }, + [0x145] = { "noop", "" }, + [0x144] = { "restorecamera", "w", {} }, [0x147] = { "restorecamera", "w", {} }, + [0x140] = { "restorecamera", "" }, + [0x1C] = { "callcommonscript", "w" }, + -- the direct compare and the jump on it: 0x19 var, value then 0x1F with a + -- real condition (1 = equal in all 109 such jumps) -- Platinum's pair + [0x19] = { "comparevartovalue", "ww" }, + [0x1F] = { "gotoif", "bd" }, + -- THE GIFT BOX (the bedroom, member 782): + -- 0x153 the starter app; the script reads 0 / 1 / 2 back and + -- turns it into 495 / 498 / 501 itself + -- 0x10C the gift (`10C 8010 8021 0 5` + -- after the fanfare and "{VAR1} chose {VAR2}!") -- Platinum's + -- givepokemon + -- 0x057 a tail-jump stub into the name buffer: `057 + -- 01 01EF` before "chose {VAR2}!", `057 01 01F5` before Cheren's + -- "I wanted {VAR2} from the start" + -- 0x1AE / 0x14B before the app and 0x14A / 0x1AF after it bracket it + -- with the field's fade; 0x21C follows the answer + [0x153] = { "bwchoosestarter", "w" }, + [0x10C] = { "givepokemon", "wwww", { 2, 4, 3, 1 } }, + [0x057] = { "bufferspeciesnamefromvar", "bw" }, + [0x1AE] = { "noop", "" }, [0x1AF] = { "noop", "" }, + [0x14A] = { "noop", "" }, [0x14B] = { "noop", "" }, + [0x21C] = { "noop", "ww" }, + -- AFTER THE BEDROOM BATTLES: + -- 0x101 1 when the slot's HP is full or it is an Egg + -- (GetMonData 0xA0 against 0xA1, then 0x4C) -- before Cheren's + -- "you won without your Pokemon's losing any energy" + -- 0x104 heals the party (party from the save, straight into the heal): + -- right after "Cheren healed {VAR1}'s Pokemon!" + -- 0x053 / 0x054 byte then value like 0x057: a + -- party Pokemon's species name / its nickname into a buffer (two + -- neighbouring buffer routines; 0x054 is what Juniper's "Are you OK + -- with the nickname {VAR1}?" prints) + -- 0x127 / 0x129 / 0x12A / + -- 0x128 bracket the battle's return with an effect on a + -- handle var (0x8022); the scene reads nothing back from them + [0x101] = { "bwmonhpfull", "ww" }, + [0x104] = { "healparty", "" }, + [0x053] = { "bufferpartymonspecies", "bw" }, [0x054] = { "bufferpartymonnickname", "bw" }, + [0x127] = { "noop", "wwww" }, [0x129] = { "noop", "ww" }, + [0x12A] = { "noop", "w" }, [0x128] = { "noop", "w" }, + -- THE BAG, in Platinum's order and shapes (consecutive handlers in overlay + -- 10, each into one bag routine): 0xB5 adds (the + -- give-item script, member 862, runs it before the fanfare), 0xB6 takes, + -- 0xB7 asks whether it fits (the same script's `if (dest == 0)` bag-full + -- branch), 0xB8 asks whether there are that many; 0xB9 the + -- count + -- 0x04E the item's name into a buffer (the + -- plural when quantity > 1), before "obtained" + [0xB5] = { "additem", "www" }, [0xB6] = { "removeitem", "www" }, + [0xB7] = { "canfititem", "www" }, [0xB8] = { "checkitem", "www" }, + [0xB9] = { "getitemquantity", "ww" }, + [0x04E] = { "bufferitemname", "bwwb", { 1, 2 } }, + -- 0xBB the item's parameter 5, its pocket (0..4, see + -- Gen5Data's bwPocket); the give-item script picks its jingle + -- and its "put the ... in the ... Case" line on it + -- 0x052 the pocket's name (system bank 55) + [0xBB] = { "getitempocket", "ww" }, + [0x052] = { "bufferpocketname", "bw" }, + -- JUNIPER'S NICKNAME LOOP (the lab, member 792): + -- 0x105 the naming screen for that party Pokemon -- + -- Platinum's openpokemonnamingscreen + -- 0x110 1 when it now carries a nickname; on 1 the + -- script buffers it (0x054) and asks "Are you OK with the nickname + -- {VAR1}?", and a NO goes round again + -- 0x1AD the fade back in after the screen (0x1AE's pair) + -- 0x19F run with no badges, between `setflag 0x962` (the + -- Pokedex) and "obtained the Pokedex!"; nothing reads it back + [0x105] = { "openpokemonnamingscreen", "www", { 2, 1 } }, + [0x110] = { "bwmonhasnickname", "www" }, + [0x1AD] = { "noop", "" }, [0x19F] = { "noop", "w" }, + -- ROUTE 1 (member 634): 0x17D is Juniper's catching demonstration, between + -- "I'll show you how to catch a Pokemon" and "Did you see that, just + -- now?" -- its routine builds a level 7 Minccino (Pound, Growl) against a + -- wild level 2 Patrat (Tackle, Leer) and the handler pauses the script on + -- it. 0x179 starts a field task after it that reads nothing back. + [0x17D] = { "bwcatchingtutorial", "" }, [0x179] = { "noop", "" }, + -- ACCUMULA (member 794): 0x0BE , four literals, is a + -- warp -- Juniper's `0BE 398 7 19 0` takes the player into the Pokemon + -- Center for her tour. Platinum's warp is , so + -- the zone fills the unused slot too. (0x127 .. 0x12A above are the door + -- opening: `127 handle, 1, 796, 657` is the Center's door, matrix tile + -- (28, 17) of the town.) + [0x0BE] = { "warp", "wwww", { 1, 1, 2, 3, 4 } }, + -- 0x0C2: the same four literals with another transition (Fennel's "follow + -- me" outside Striaton's gym: `0C2 9 7 10 0`, into her lab) + [0x0C2] = { "warp", "wwww", { 1, 1, 2, 3, 4 } }, + -- THE NURSE (shared band 2100, member 855, every Pokemon Center): + -- 0x0CE the time of day, picking "Good morning!" or "Hello" + -- 0x0D0 today, 0x0D4 the DS profile's + -- birthday: equal, and she asks "Hey! Is today your birthday?" + -- (bank 346 #3). Unread, both were 0 = 0 and she asked every time + -- 0x100 Pokerus in the party (then `if (var == 1 && !flag 0x65)` + -- prints #12, "may be infected with the Pokerus") + -- 0x075 / 0x23A / 0x103 / 0x12F her + -- bow, a record counter and the healing machine's balls -- nothing + -- reads them back (0x103's var only feeds 0x12F) + [0x0CE] = { "gettimeofday", "w" }, + [0x0D0] = { "bwgetdate", "ww" }, [0x0D4] = { "bwgetbirthday", "ww" }, + [0x100] = { "checkpartypokerus", "w" }, + [0x075] = { "noop", "w" }, [0x23A] = { "noop", "ww" }, + [0x103] = { "partycountnotegg", "ww", { 1 } }, [0x12F] = { "noop", "w" }, + -- THE FLOATING WINDOWS: 0x03A prints a line in a + -- small window of its own at a tile position -- the crowd's murmurs + -- around Ghetsis (`03A 000A 20 10 1`, `03A 000B 22 15 2`, then a wait for + -- A) -- and 0x03B takes that window down. Shown here in the + -- ordinary box; WHERE it floats is not carried. + [0x03A] = { "message", "wwww", { 1 } }, [0x03B] = { "noop", "w" }, + -- ROUTE 2 (member 640): Mom's Xtransceiver call. 0x0E3 sets a system flag + -- from a constant, with no operands, between "I have another present for + -- you" and "received a pair of Running Shoes!" -- Platinum's + -- giverunningshoes + [0x0E3] = { "giverunningshoes", "" }, + -- 0x068 : the player's MATRIX position -- Striaton's guard + -- runs `068 8020 8021; if (8020 == 750)`, 750 being the town's origin 736 + -- plus the trigger's first column -- Platinum's getplayermappos + -- 0x0E0 : the version id, the handler's own constant (21 in Black); + -- scripts branch `if (var == 21)` + [0x068] = { "getplayermappos", "ww" }, + [0x0E0] = { "bwgetversion", "w" }, + -- 0x056 : a type's name into a buffer, byte first like 0x057 -- + -- the Striaton leaders' "the partner you first chose was {VAR1} type" + [0x056] = { "buffertypename", "bw" }, + -- 0x04D / 0x050 : the item's name and the move a machine + -- carries -- the gym's TM gift runs `4D 0 410; 50 1 410` before "{VAR1} + -- contains Work Up". 0x04D is a stub into the shared byte-then-value + -- buffer routine (Gen5ScriptOps' resolve) + [0x04D] = { "bufferitemname", "bw" }, [0x050] = { "buffertmhmmovename", "bw" }, + -- 0x04F : a machine's name ("HM01") before "obtained {VAR2} + -- {VAR3}" -- Fennel's Cut; the same stub shape as 0x04D + [0x04F] = { "bufferitemname", "bw" }, + -- 0x116 : the first party slot knowing that move, 6 for none -- + -- the Cut tree's shared script (band 10000 #4) runs `116 8010 15; if + -- (8010 == 6) { "This tree looks like it can be cut down!" }` -- + -- Platinum's findpartyslotwithmove + [0x116] = { "findpartyslotwithmove", "ww" }, + -- THE SHARED TRAINER SCRIPT (band 3000, member 863, every trainer): + -- 0x084 the trainer's id (Platinum's gettrainerid) + -- 0x097 beaten? 0x092 healer / prize + -- 0x08A the battle type, 1 for a double (2 triple, 3 + -- rotation; Platinum's double check answers only the first) + -- 0x103 the party's count, checked against the type's need + -- (the nurse's machine reads it too) + -- 0x08B the encounter music + -- 0x088 which lines (Platinum's + -- gettrainermessagetypes) and 0x087 says one + -- (printtrainerdialogue), from system bank 189 + -- 0x066 / 0x073 the object's movement + -- type, read to turn the trainer, and set back + -- 0x086 : a tag battle -- Wellspring Cave's + -- `086 56 62 63 0` is Cheren (56) beside you against two grunts -- + -- Platinum's multibattle + [0x086] = { "multibattle", "wwww", { 1, 2, 3 } }, + [0x084] = { "gettrainerid", "w" }, + -- 0x095 / 0x096 set / clear its beaten flag (the event flag at + -- a fixed base plus the id; 0x097 reads the same one) -- run after + -- every trainer win + [0x095] = { "settrainerflag", "w" }, [0x096] = { "cleartrainerflag", "w" }, + [0x097] = { "bwtrainerbeaten", "ww" }, [0x092] = { "bwtrainerafter", "ww" }, + [0x08A] = { "checkistrainerdoublebattle", "ww", { 2 } }, + [0x08B] = { "noop", "w" }, + [0x088] = { "gettrainermessagetypes", "www" }, + [0x087] = { "printtrainerdialogue", "www", { 1, 2 } }, + [0x066] = { "bwobjectmovement", "ww" }, [0x073] = { "noop", "ww" }, +} + +-- THE STACK FORM. Most of BW's conditional jumps are an expression on the +-- script stack and then 0x1F 0xFF target, "jump if the top is FALSE" -- the +-- handler (0x02159670 in Black) pops it and falls through on 1, which is a +-- compiled `if (expression) { ... }` jumping past its body. (Read the other +-- way round, Z054's masseuse was "exhausted" on a fresh save: she checks +-- `flag 0xAAE == 0` and sets 0xAAE herself after a massage.) +-- +-- 0x08 push constant 0x09 push var 0x10 push flag (1 set, 0 clear) +-- 0x11 op pops b, then a, pushes `a op b` +-- +-- The operators, read off 0x11's handler (overlay 10, 0x021594E0 in Black): +-- 0 < 1 == 2 > 3 <= 4 >= 5 != (unsigned), 6 OR, 7 AND (each side == 1). +-- The usual shape is one comparison (2,192 jumps); the rest join several +-- with 6 / 7, e.g. `var 0x408A == 3 AND flag 0x80 == 0`. +-- +-- Platinum has one compare and a jump on its result, so an expression is +-- rewritten as a chain of them ("jumping code") that jumps when the +-- expression fails: a failing AND jumps on either side, a failing OR skips +-- past the jump when its left side holds. A var is compared to +-- the constant (or to another var) and the jump takes the operator (or its +-- negation) as its condition; a flag is checked and the condition is the +-- Gen 4 code whose answer for clear and for set is exactly `flag op +-- constant` (or its negation) -- all four combinations have one. The skip lands on the instruction after the 0x1F, +-- which the pool decodes as an entry of its own. +local OPS = { + [0] = function(a, b) return a < b end, function(a, b) return a == b end, + function(a, b) return a > b end, function(a, b) return a <= b end, + function(a, b) return a >= b end, function(a, b) return a ~= b end, +} +-- Gen 4 condition by its answer for result 0 (clear) and result 1 (set) +local FLAG_CONDITION = { ["TF"] = 0, ["FT"] = 1, ["TT"] = 3, ["FF"] = 2 } +-- the comparison that holds when this one fails, and the one for swapped sides +local NEGATE = { [0] = 4, 5, 3, 2, 0, 1 } +local SWAP = { [0] = 2, 1, 0, 4, 3, 5 } + +local function u16le(s, o) return (s:byte(o) or 0) + (s:byte(o + 1) or 0) * 256 end + +-- parse(decoded, i) -> the expression, the index of its 0x1F, or nil +local function parse(decoded, i) + local stack = {} + for k = i, #decoded do + local ins = decoded[k] + if ins.op == 0x08 then stack[#stack + 1] = { kind = "const", value = u16le(ins.operands, 1), at = ins.at } + elseif ins.op == 0x09 then stack[#stack + 1] = { kind = "var", value = u16le(ins.operands, 1), at = ins.at } + elseif ins.op == 0x10 then stack[#stack + 1] = { kind = "flag", value = u16le(ins.operands, 1), at = ins.at } + elseif ins.op == 0x11 then + local op = u16le(ins.operands, 1) + local b, a = table.remove(stack), table.remove(stack) + if not (a and b) or op > 7 then return nil end + stack[#stack + 1] = { kind = "op", op = op, a = a, b = b, at = a.at } + elseif ins.op == 0x1F and ins.operands:byte(1) == 0xFF then + if #stack == 1 and stack[1].kind == "op" then return stack[1], k end + return nil + else + return nil + end + end +end + +-- emit(node, target, sense, tail, out, skip) -> true when lowered: jumps to +-- `target` when the node's truth is `sense`, else falls through. A skip past +-- the whole jump is only reachable from the tail of the chain. +local function emit(node, target, sense, tail, out, skip) + if node.kind ~= "op" then return false end + local op, a, b = node.op, node.a, node.b + if op == 6 or op == 7 then + if (op == 6) == sense then -- OR jumping on true / AND on false + return emit(a, target, sense, false, out, skip) and emit(b, target, sense, tail, out, skip) + end + if not tail then return false end -- AND on true / OR on false + return emit(a, skip, not sense, false, out, skip) and emit(b, target, sense, tail, out, skip) + end + -- a comparison: put the var or flag on the left + if a.kind == "const" and b.kind ~= "const" then a, b, op = b, a, SWAP[op] end + -- `(expression) == 1` / `!= 0` is the expression; `== 0` / `!= 1` its negation + if a.kind == "op" and b.kind == "const" and (op == 1 or op == 5) and (b.value == 0 or b.value == 1) then + local same = (op == 1) == (b.value == 1) + return emit(a, target, same == sense, tail, out, skip) + end + local cond = sense and op or NEGATE[op] + if a.kind == "var" and b.kind == "const" then + out[#out + 1] = { at = a.at, op = 0x19, name = "comparevartovalue", args = { a.value, b.value } } + elseif a.kind == "var" and b.kind == "var" then + out[#out + 1] = { at = a.at, op = 0x19, name = "comparevartovar", args = { a.value, b.value } } + elseif a.kind == "flag" and b.kind == "const" then + local f = OPS[op] + local key = (f(0, b.value) == sense and "T" or "F") .. (f(1, b.value) == sense and "T" or "F") + out[#out + 1] = { at = a.at, op = 0x10, name = "checkflag", args = { a.value } } + cond = FLAG_CONDITION[key] + else + return false + end + out[#out + 1] = { at = a.at, op = 0x1F, name = "gotoif", args = { cond }, target = target } + return true +end + +function L.rewriteStack(decoded) + local out, i = {}, 1 + while i <= #decoded do + local a = decoded[i] + local node, k + if a.op == 0x08 or a.op == 0x09 or a.op == 0x10 then node, k = parse(decoded, i) end + local chain = {} + local j = k and decoded[k] + if node and emit(node, j.target, false, true, chain, j.at + 2 + j.width) then + for _, ins in ipairs(chain) do out[#out + 1] = ins end + i = k + 1 + else + out[#out + 1] = L.name(a) + i = i + 1 + end + end + return out +end + +-- SAVE AND RESTORE. Outside a condition 0x09 / 0x0A are a push and a pop, +-- and nearly every one brackets a common-script call: push 0x8000, push +-- 0x8001, set them as the callee's arguments, call, pop 0x8001, pop 0x8000. +-- Platinum has no script stack, so a push becomes a copy into a slot var +-- (one per depth, from 0x8100 -- the cartridge's own temporary vars stop +-- at 0x8054) and a pop copies it back. Depth is tracked through each block in +-- order; a pop with nothing pushed in the block stays unnamed. +L.SLOT_BASE = 0x8100 +-- a var-or-value operand: 0x4000 and up names a var (Gen 4's rule too) +function L.setvarName(value) return (value or 0) >= 0x4000 and "setvarfromvar" or "setvarfromvalue" end +function L.rewritePushPop(list) + local depth = 0 + for _, ins in ipairs(list) do + if ins.name == "bw_009" and ins.operands then + local value = (ins.operands:byte(1) or 0) + (ins.operands:byte(2) or 0) * 256 + ins.name, ins.args = L.setvarName(value), { L.SLOT_BASE + depth, value } + depth = depth + 1 + elseif ins.name == "bw_00A" and ins.operands and depth > 0 then + depth = depth - 1 + ins.name, ins.args = "setvarfromvar", { (ins.operands:byte(1) or 0) + (ins.operands:byte(2) or 0) * 256, L.SLOT_BASE + depth } + end + end + return list +end + +-- COMMANDS THAT ARE SEVERAL OF PLATINUM'S. Each expands into Platinum rows, +-- and where it needs a choice the skip lands on the instruction after it, +-- which the pool decodes as an entry like any other jump target. +-- +-- 0x107 the party menu: ok = 1 and the slot +-- when a Pokemon was picked, ok = 0 on cancel. Z054's masseuse: +-- `107 0x8022 0x8021 0`, then "Oh, I see" unless 0x8022 == 1. +-- 0x102 whether that slot holds an Egg (she answers a +-- non-zero one with "Massage the Egg? It may be a bit too early"). +-- Platinum's species read gives an Egg as 0, so dest = species == 0. +-- 0xB3 a brightness fade, 0 -> 16 darkening +-- and 16 -> 0 brightening; Platinum's fadescreen takes an even type +-- to darken and an odd one to brighten, with steps x frames per step +-- 0xFD friendship: mode 1 adds (the masseuse's +-- 30 / 10 / 5 before "much more / more / a little more friendly"); +-- Platinum's increasepartymonfriendship is +L.KEEP_OPERANDS = { [0x09] = true, [0x0A] = true, [0x107] = true, [0x102] = true, + [0xB3] = true, [0xFD] = true, [0x8D] = true, [0x143] = true } +L.SCRATCH = 0x8141 +local function word(ins, k) return (ins.operands:byte(k) or 0) + (ins.operands:byte(k + 1) or 0) * 256 end +function L.rewriteCompound(list) + local out = {} + local function add(ins, name, args, target) + out[#out + 1] = { at = ins.at, op = ins.op, name = name, args = args, target = target } + end + for _, ins in ipairs(list) do + local nextAt = ins.width and ins.at + 2 + ins.width + if ins.name == "bw_107" and ins.operands then + local ok, slot = word(ins, 1), word(ins, 3) + add(ins, "openpartymenufortrade", {}) + add(ins, "getselectedpartyslot", { slot }) + add(ins, "setvarfromvalue", { ok, 1 }) + add(ins, "comparevartovalue", { slot, 0xFF }) + add(ins, "gotoif", { 5 }, nextAt) + add(ins, "setvarfromvalue", { ok, 0 }) + elseif ins.name == "bw_102" and ins.operands then + local dest, slot = word(ins, 1), word(ins, 3) + add(ins, "getpartymonspecies", { slot, L.SCRATCH }) + add(ins, "setvarfromvalue", { dest, 1 }) + add(ins, "comparevartovalue", { L.SCRATCH, 0 }) + add(ins, "gotoif", { 1 }, nextAt) + add(ins, "setvarfromvalue", { dest, 0 }) + elseif ins.op == 0x43 and (ins.name == "message" or ins.name == "messagevar") then + out[#out + 1] = ins + add(ins, "waitabpress", {}) + elseif ins.name == "bw_08D" and ins.operands then + -- Platinum's checkwonbattle: 1 for a win, as BW's (and it marks the + -- trainer beaten) + add(ins, "checkwonbattle", { word(ins, 1) }) + elseif ins.name == "bw_143" and ins.operands and #ins.operands >= 22 then + -- : the + -- point to look at in world units, 16 to a tile, so tile = fx32 / 65536 + -- -- ABSOLUTE matrix tiles, which addfreecamera takes as Platinum's + -- does. The gym's (12.5, 3.4) is where Z007's three leaders stand; + -- Nacrene's (644.5, 583.5) is local (36.5, 7.5) from its origin + -- (608, 576). The cartridge's pitch, distance and frames are not + -- carried: the player's own camera mode decides those here. + local function fx(k) local v = word(ins, k) + word(ins, k + 2) * 65536 + if v >= 0x80000000 then v = v - 0x100000000 end return v end + add(ins, "addfreecamera", { math.floor(fx(9) / 65536), math.floor(fx(17) / 65536) }) + elseif ins.name == "bw_0B3" and ins.operands then + local from, to, speed = word(ins, 3), word(ins, 5), word(ins, 7) + add(ins, "fadescreen", { 6, math.max(1, speed), to > from and 0 or 1, 0 }) + elseif ins.name == "bw_0FD" and ins.operands and word(ins, 5) == 1 then + add(ins, "increasepartymonfriendship", { word(ins, 3), word(ins, 1) }) + else + out[#out + 1] = ins + end + end + return out +end + +-- THE SCRIPT ID TABLE. A script id above a map's own picks a shared file by +-- range: { u16 first, u16 last, u16 script member, u16 3, u16 text bank }, +-- 46 records in descending order (overlay 10, 0x021701A8 in Black), e.g. +-- 2000-2099 -> member 854 with bank 158, 10110-10119 -> 871. Found by +-- shape: the run of such records holding { 2000, 2099 }. The bands Gen 4 +-- also has keep Gen 4's names (common scripts, the two battle bands on one +-- file, visible and hidden items); the rest are named by their first id. +local GEN4_NAMES = { [2000] = "common_scripts", [3000] = "single_battles", [5000] = "double_battles", + [7000] = "visible_items", [8000] = "hidden_items" } +function L.findBands(rom) + local blobs = { rom:arm9() } + for o = 0, 400 do + local ok, b = pcall(function() return rom:overlay(o) end) + if ok and b then blobs[#blobs + 1] = b elseif o > 200 then break end + end + local function valid(b, k) + if k < 0 or k + 10 > #b then return nil end + local f, l, m, t, txt = u16le(b, k + 1), u16le(b, k + 3), u16le(b, k + 5), u16le(b, k + 7), u16le(b, k + 9) + if t == 3 and f <= l and f >= 1 then return { f, l, m, txt } end + end + local key = string.char(0xD0, 0x07, 0x33, 0x08) -- 2000, 2099 + for _, b in ipairs(blobs) do + local at = 1 + while true do + local i = b:find(key, at, true) + if not i then break end + local k = i - 1 + if valid(b, k) then + local rows = {} + while valid(b, k) do + local r = valid(b, k) + rows[#rows + 1] = { r[1], GEN4_NAMES[r[1]] or ("bw_" .. r[1]), r[3], r[4], last = r[2] } + k = k - 10 + end + if #rows >= 10 then + table.sort(rows, function(x, y) return x[1] > y[1] end) + return rows + end + end + at = i + 1 + end + end + return nil +end + +local function split(operands, spec) + local args, o = {}, 1 + for i = 1, #spec do + local c = spec:sub(i, i) + local b = function(k) return operands:byte(o + k) or 0 end + if c == "b" then args[#args + 1] = b(0); o = o + 1 + elseif c == "w" then args[#args + 1] = b(0) + b(1) * 256; o = o + 2 + elseif c == "d" then args[#args + 1] = b(0) + b(1) * 256 + b(2) * 65536 + b(3) * 16777216; o = o + 4 end + end + return args +end + +-- name(ins) -> the instruction with Platinum's name and arguments +function L.name(ins) + local alias = L.ALIASES[ins.op] + if not alias or #alias[2] == 0 and ins.width ~= 0 then + return { at = ins.at, op = ins.op, name = ("bw_%03X"):format(ins.op), args = {}, target = ins.target, + operands = L.KEEP_OPERANDS[ins.op] and ins.operands or nil, + width = L.KEEP_OPERANDS[ins.op] and ins.width or nil } + end + local args = split(ins.operands, alias[2]) + if alias[3] then + local picked = {} + for i, k in ipairs(alias[3]) do picked[i] = args[k] end + args = picked + end + if alias[1] == "noop" then args = {} end + local name = alias[1] + if name == "setvar" then name = L.setvarName(args[2]) end + if name == "message" and (args[1] or 0) >= 0x4000 then name = "messagevar" end + return { at = ins.at, op = ins.op, name = name, args = args, target = ins.target } +end + +-- THE NEW-GAME SCRIPT: newGame(archive, widths, bandRows) -> { flags, vars } +-- A new game hides every actor whose story has not begun by running one +-- shared script of nothing but `setflag`s (Platinum's 112, Hoenn's 159). +-- Black's is a subroutine band 9600's (member 866) entry calls: 131 setflags, one +-- `0x4116 = 1`, and a return. Found by that shape -- the one shared script +-- whose every row is a setflag, a set var or its end, at least 100 of them -- +-- not by its id. Without it Nuvema's and Accumula's gated people stand on the +-- map from the first frame. +function L.newGame(arc, widths, bandRows) + local Script = require("src.import.Gen4Script") + local best + local seen = {} + for _, row in ipairs(bandRows or {}) do + local m = row[3] + local d = not seen[m] and m < arc.count and arc:get(m) + seen[m] = true + if d then + -- the entries and every block they call or jump to (the flag list is + -- a subroutine the band's entry calls) + local queue, visited = {}, {} + for _, at in ipairs(Script.entries(d)) do queue[#queue + 1] = at end + local qi = 1 + while qi <= #queue do + local at = queue[qi]; qi = qi + 1 + if visited[at] then goto continue end + visited[at] = true + local decoded = Ops.decode(d, at, widths) + for _, ins in ipairs(decoded) do + if ins.target and ins.target >= 1 and ins.target <= #d then queue[#queue + 1] = ins.target end + end + local flags, vars, clean = {}, {}, true + for _, ins in ipairs(decoded) do + local named = L.name(ins) + if named.name == "setflag" then flags[#flags + 1] = ("FLAG_G4_%04X"):format(named.args[1]) + elseif named.name == "setvarfromvalue" or named.name == "setvar" then + vars[#vars + 1] = { id = named.args[1], value = named.args[2] } + elseif named.name ~= "return" and named.name ~= "end" then clean = false break end + end + if clean and #flags >= 100 and (not best or #flags > #best.flags) then + best = { flags = flags, vars = vars, member = m, at = at, + source = ("ROM:/a/0/5/7 member %d at %d"):format(m, at) } + end + ::continue:: + end + end + end + return best +end + +-- pool(archive, widths, zones, maps) -> map_scripts, report +-- A ZONE'S INIT-SCRIPT FILE (the zone header's `initScripts`, a member of +-- the same archive): 6-byte entries -- u16 type, u32 value -- ended by a u16 +-- 0. Type 1 is a frame table, its value an offset from the end of its own +-- entry to rows of u16 var, u16 value, u16 script, ended by a u16 0; the +-- other types name a script outright. The numbering is HGSS's (2 on +-- transition, 3 on resume, 4 on load). Read off Nuvema's: the player's room +-- (783) is one frame table, var 0x4081 == 0 -> script 5, the opening. +L.INIT_NAMES = { [1] = "frame_table", [2] = "on_transition", [3] = "on_resume", [4] = "on_load" } +function L.initScripts(d) + local callbacks, tables = {}, {} + if type(d) ~= "string" then return callbacks, tables end + local function u16(o) return d:byte(o + 1) + d:byte(o + 2) * 256 end + local function u32(o) return u16(o) + u16(o + 2) * 65536 end + local o = 0 + while o + 2 <= #d do + local kind = u16(o) + if kind == 0 or o + 6 > #d then break end + local value = u32(o + 2) + if kind == 1 then + local rows, r = {}, o + 6 + value + while r + 6 <= #d and u16(r) ~= 0 do + rows[#rows + 1] = { var = u16(r), value = u16(r + 2), script = u16(r + 4) } + r = r + 6 + end + tables[#tables + 1] = { type = 1, name = L.INIT_NAMES[1], rows = rows } + else + callbacks[#callbacks + 1] = { type = kind, name = L.INIT_NAMES[kind] or ("init_" .. kind), script = value } + end + o = o + 6 + end + return callbacks, tables +end + +function L.pool(arc, widths, zones, maps, bandRows) + local Script = require("src.import.Gen4Script") + local VM = require("src.script.Gen4ScriptVM") + local scripts, entriesOf, named, total, moves = {}, {}, 0, 0, 0 + -- A SHARED FILE'S `end` IS A RETURN. 0x1C runs the common script in a + -- context of its own and waits for it, so the `end` closing the give-item + -- script (member 862: `B5 item; ... 01D; end`) ends only that context and + -- Mom's scene carries on after her Xtransceiver. Lowered as Platinum's + -- call, that `end` ended the caller too. + local shared = {} + for _, row in ipairs(bandRows or {}) do shared[row[3]] = true end + local function decodeMember(m) + if entriesOf[m] then return entriesOf[m] end + local d = m < arc.count and arc:get(m) + if not d then entriesOf[m] = {} return entriesOf[m] end + local entries = Script.entries(d) + local queue, seen = {}, {} + for _, at in ipairs(entries) do queue[#queue + 1] = at end + local i = 1 + while i <= #queue do + local at = queue[i]; i = i + 1 + if not seen[at] then + seen[at] = true + local decoded, stopped = Ops.decode(d, at, widths) + for _, ins in ipairs(decoded) do + if ins.target and ins.target >= 1 and ins.target <= #d then queue[#queue + 1] = ins.target end + end + local list = L.rewriteCompound(L.rewritePushPop(L.rewriteStack(decoded))) + for _, ins in ipairs(list) do + if ins.target and ins.target >= 1 and ins.target <= #d and not seen[ins.target] then queue[#queue + 1] = ins.target end + end + for _, ins in ipairs(list) do + total = total + 1 + if not ins.name:match("^bw_") then named = named + 1 end + if ins.name == "gotoif" and ins.args[1] == 0xFF then ins.name = "bw_01F" end + if shared[m] and ins.name == "end" then ins.name = "return" end + if ins.name == "applymovement" then + local address = Movement.addressOf(ins, 8) + ins.movement = address and Movement.decode(d, address) or nil + if ins.movement then moves = moves + 1 end + end + end + scripts[VM.label(m, at)] = { instructions = list, stopped = stopped } + end + end + local labels = {} + for k, at in ipairs(entries) do labels[k] = VM.label(m, at) end + entriesOf[m] = labels + return labels + end + local out, linked = {}, 0 + for mapId, def in pairs(maps) do + local z = zones[def.header] + if z then + local labels = decodeMember(z.scripts) + local rec = { header = def.header, objects = {}, signs = {} } + -- A SCRIPT ID PAST THE ZONE'S OWN names a shared file: the Pokemon + -- Center's nurse is 2100 and its PC 2101 in every town. Filed the way + -- the Gen 4 pool files them (`shared.objects[index] = { band, entry }`) + -- so Gen4ScriptVM resolves the block and reads the band's text bank. + local function shared(kind, index, id) + for _, row in ipairs(bandRows or {}) do + if id >= row[1] and id <= (row.last or row[1]) then + rec.shared = rec.shared or {} + rec.shared[kind] = rec.shared[kind] or {} + rec.shared[kind][index] = { band = row[2], entry = id - row[1] } + linked = linked + 1 + return + end + end + end + for _, o in ipairs(def.objects or {}) do + local label = labels[o.script] + if label then rec.objects[o.index] = label; linked = linked + 1 + elseif o.script and o.script >= 1000 then shared("objects", o.index, o.script) end + end + for _, s in ipairs(def.signs or {}) do + local label = labels[s.script] + if label then rec.signs[s.index] = label; linked = linked + 1 + elseif s.script and s.script >= 1000 then shared("signs", s.index, s.script) end + end + -- the coordinate triggers (Accumula's welcome, Route 1's exit), filed + -- under the event's index as Gen4ScriptVM joins them + for _, c in ipairs(def.coordEvents or {}) do + local label = c.script and labels[c.script] + if label then + rec.coords = rec.coords or {} + rec.coords[c.index] = label; linked = linked + 1 + end + end + -- the zone's init scripts, in HGSS's record shape + local initBytes = z.initScripts and z.initScripts < arc.count and arc:get(z.initScripts) + local callbacks, tables = L.initScripts(initBytes) + for _, c in ipairs(callbacks) do + local label = labels[c.script] + if label then + rec.callbacks = rec.callbacks or {} + rec.callbacks[#rec.callbacks + 1] = { type = c.type, name = c.name, script = label } + end + end + for _, t in ipairs(tables) do + local rows = {} + for _, row in ipairs(t.rows) do + local label = labels[row.script] + if label then rows[#rows + 1] = { a = row.var, b = row.value, script = label } end + end + if #rows > 0 then + rec.tables = rec.tables or {} + rec.tables[#rec.tables + 1] = { type = 1, name = t.name, rows = rows } + end + end + out[mapId] = rec + end + end + -- the shared files, in the shape Gen4ScriptVM's `bandLabel` reads + local bands = {} + for _, row in ipairs(bandRows or {}) do + if not bands[row[2]] then + bands[row[2]] = { member = row[3], textBank = row[4], entries = decodeMember(row[3]) } + end + end + for _, ins in pairs(scripts) do + for _, i in ipairs(ins.instructions) do i.operands, i.width = nil, nil end + end + return { source = "RomExtractorGen4", scripts = scripts, maps = out, bands = bands }, + { instructions = total, named = named, linked = linked, movements = moves } +end + +return L diff --git a/src/import/Gen5ScriptOps.lua b/src/import/Gen5ScriptOps.lua new file mode 100644 index 000000000..c951b577a --- /dev/null +++ b/src/import/Gen5ScriptOps.lua @@ -0,0 +1,291 @@ +-- BLACK AND WHITE'S SCRIPT COMMAND WIDTHS, DERIVED FROM THE CARTRIDGE'S CODE. +-- +-- There is no decompilation of Gen 5's script engine to transcribe a table +-- from, and no published one. So the widths are READ OUT OF THE HANDLERS, the +-- way Gen4ScriptOps' were read out of pokeplatinum's C -- here from the Thumb +-- code itself: +-- +-- 1. THE TABLE. A run of handler pointers (Thumb, odd addresses, a few null +-- slots for unused commands) in overlay 10: 624 slots, the handlers in +-- overlays 10, 18, 20 and 21. +-- 2. THE READERS. A handler takes its operands with small helpers that load +-- the context's script pointer (offset 0x14), read bytes from it and +-- store it back: one reads a halfword (two ldrb), one a word (four). +-- Found by that shape, not by address. +-- 3. A COMMAND'S WIDTH is what its handler reads: bytes read inline from the +-- script pointer, plus the width of every function it calls (helpers +-- that wrap a reader -- "a var or a value" -- count through). ONE call +-- deep: 0x1C (run common script , 0x021595CC in Black) reads one +-- halfword, but the loader it calls reads a script header through a +-- pointer of the same shape, and following it made 0x1C six bytes wide +-- -- swallowing the `pop var` after every common-script call. Stopping +-- at the handler's own callees fixes 0x1B, 0x1C and 0x1D3 and changes +-- no other width (3,233 clean entries against 3,228; 3,349 once tail-jump +-- stubs are followed, 3,360 once a stub's jump may come second -- see +-- `resolve`). +-- 4. THE ALIGNMENT. The table's first slot is not opcode 0: decoding every +-- script in /a/0/5/7 at each candidate offset, one offset ends 3,360 of +-- 3,383 entry points cleanly (Black) and the next best a third of that. +-- The cartridge's own scripts pick it. +-- +-- Checked by tools/gen5_import_check.lua, which also pins the control flow +-- the scripts themselves show: End 0x02 and Return 0x05 take nothing, Call +-- 0x04 and Jump 0x1E a u32, the conditional jump 0x1F a u8 and a u32. + +local Ops = {} + +Ops.TERMINATORS = { [0x02] = true, [0x05] = true, [0x1E] = true } +Ops.JUMPS = { [0x04] = "call", [0x1E] = "jump", [0x1F] = "if" } + +local function u16(s, o) return s:byte(o + 1) + s:byte(o + 2) * 256 end +local function u32(s, o) return u16(s, o) + u16(s, o + 2) * 65536 end + +-- segments: every overlay and the ARM9, by RAM address +local function segments(rom) + local segs = { { b = rom:arm9(), ram = rom:header().arm9.ram, name = "arm9" } } + for o = 0, 200 do + local ok, b, info = pcall(function() return rom:overlay(o) end) + if not ok or not b then if o > 160 then break end else + segs[#segs + 1] = { b = b, ram = info.ram, name = "ov" .. o, overlay = o } + end + end + return segs +end + +-- find the handler table: the longest run of Thumb pointers (nulls allowed) +local function findTable(segs) + local function isCode(v) + if v % 2 == 0 then return false end + for _, s in ipairs(segs) do if v - 1 >= s.ram and v - 1 < s.ram + #s.b then return true end end + return false + end + local best + for _, s in ipairs(segs) do + if s.overlay then + local b, at = s.b, 0 + while at + 4 <= #b do + local n, good, run = 0, 0, 0 + while at + (n + 1) * 4 <= #b do + local v = u32(b, at + n * 4) + if isCode(v) then good = good + 1; run = 0 + elseif v == 0 then run = run + 1; if run > 3 then break end + else break end + n = n + 1 + end + if good >= 250 and good / math.max(n, 1) > 0.9 and (not best or good > best.good) then + best = { seg = s, at = at, n = n, good = good } + end + at = at + (n > 0 and n * 4 or 4) + end + end + end + return best +end + +-- the handler's code. OVERLAYS SHARE ADDRESSES, so the segments are tried in +-- the order `rank` sets: the overlays most of the table's handlers start a +-- function in (10, 21, 20, 18 in Black) ahead of the rest, the ARM9 last -- +-- the first match that starts with a push wins. +local function rank(segs, handlers) + for _, s in ipairs(segs) do + s.hits = 0 + for _, h in pairs(handlers) do + local a = h - 1 - s.ram + if a >= 0 and a + 2 <= #s.b then + local ins = u16(s.b, a) + if math.floor(ins / 256) == 0xB5 or math.floor(ins / 256) == 0xB4 then s.hits = s.hits + 1 end + end + end + if not s.overlay then s.hits = -1 end + end + table.sort(segs, function(a, b) return a.hits > b.hits end) +end + +local function codeAt(segs, addr) + local fallback + for _, s in ipairs(segs) do + local a = addr - s.ram + if a >= 0 and a + 2 <= #s.b then + local ins = u16(s.b, a) + if math.floor(ins / 256) == 0xB5 or math.floor(ins / 256) == 0xB4 then return s.b, a end + fallback = fallback or { s.b, a } + end + end + if fallback then return fallback[1], fallback[2] end +end + +local function isEnd(ins) return ins == 0x4770 or math.floor(ins / 256) == 0xBD end +local function ldrPtr(ins) return math.floor(ins / 2048) == 0x0D and math.floor(ins / 64) % 32 == 5 end +local function strPtr(ins) return math.floor(ins / 2048) == 0x0C and math.floor(ins / 64) % 32 == 5 end +local function ldrb(ins) return math.floor(ins / 2048) == 0x0F end + +local function calls(segs, addr) + local b, a = codeAt(segs, addr) + local out = {} + if not b then return out end + for i = 0, 80 do + if a + i * 2 + 4 > #b then break end + local ins = u16(b, a + i * 2) + if isEnd(ins) then break end + if math.floor(ins / 2048) == 0x1E then + local nxt = u16(b, a + i * 2 + 2) + if math.floor(nxt / 2048) == 0x1F or math.floor(nxt / 2048) == 0x1D then + local hi = ins % 2048; if hi >= 1024 then hi = hi - 2048 end + out[#out + 1] = addr + i * 2 + 4 + hi * 4096 + (nxt % 2048) * 2 + end + end + end + return out +end + +-- bytes a function reads straight from the script pointer: a handler's own +-- inline reads (the bytes read between loading the pointer and storing it +-- back), and -- for a READER, a function that loads, reads and stores the +-- pointer -- every byte it reads (the word reader stores the pointer back +-- after each byte, so a window would see one of four) +local function inlineBytes(segs, addr, asReader) + local b, a = codeAt(segs, addr) + if not b then return 0, false end + local open, windowed, all, loads, stores = false, 0, 0, 0, 0 + for i = 0, 80 do + if a + i * 2 + 2 > #b then break end + local ins = u16(b, a + i * 2) + if isEnd(ins) then break end + if ldrPtr(ins) then open = true; loads = loads + 1 end + if ldrb(ins) then all = all + 1; if open then windowed = windowed + 1 end end + if strPtr(ins) then open = false; stores = stores + 1 end + end + local reader = loads > 0 and stores > 0 and all > 0 + if asReader then return reader and all or 0, reader end + return windowed, reader +end + +-- derive(rom, scripts) -> { widths = { [op] = bytes }, offset, table, report } +function Ops.derive(rom, scriptArchive) + local segs = segments(rom) + local tbl = findTable(segs) + if not tbl then return nil, "no command table" end + local handlers = {} + for i = 0, tbl.n - 1 do + local h = u32(tbl.seg.b, tbl.at + i * 4) + if h ~= 0 then handlers[i] = h end + end + rank(segs, handlers) + local memo = {} + local function width(addr, depth) + if memo[addr] ~= nil then return memo[addr] end + if depth > 1 then return 0 end + memo[addr] = 0 + local own, reader = inlineBytes(segs, addr, true) + local w = own + if not reader then for _, c in ipairs(calls(segs, addr)) do w = w + width(c, depth + 1) end end + memo[addr] = w + return w + end + -- A TAIL-JUMP STUB is a handler too: 0x38 and 0x39 are each + -- `ldr r3, =routine; movs r2, #0 / #1; bx r3` into one message routine that + -- reads a var-or-value and an inline byte. Without following the jump they + -- measured 0 bytes and every script using them decoded askew after them + -- (`38 0025 02, 32, 39` read as `38, 25 0232 0039`). + local function resolve(addr) + for _ = 1, 3 do + local b, a = codeAt(segs, addr) + if not b then return addr end + -- the pc-relative load whose register the bx takes: the first + -- instruction (0x38's `ldr r3; movs r2; bx r3`) or the second -- 0x4D, + -- 0x4F and 0x50 are `ldr r2, =constant; ldr r3, =routine; bx r3`, and + -- missing them read 0x4D as six bytes wide and Striaton's Gym's TM + -- gift decoded askew (`4D 00 019A 0150 01` for 4D/50 and a message) + local rd, imm, at + for j = 0, 1 do + local ij = u16(b, a + j * 2) + if math.floor(ij / 2048) == 0x09 then -- ldr rD, [pc, #imm] + local r = math.floor(ij / 256) % 8 + for k = j + 1, j + 2 do + if u16(b, a + k * 2) == 0x4700 + r * 8 then -- bx rD + rd, imm, at = r, ij % 256, addr + j * 2 + end + end + end + if rd then break end + end + if not rd then return addr end + local lit = (at + 4) - (at + 4) % 4 + imm * 4 + local lb, la = codeAt(segs, lit) + if not lb then return addr end + local target = u32(lb, la) + if target % 2 == 0 then return addr end + addr = target - 1 + end + return addr + end + local slots = {} + for i = 0, tbl.n - 1 do + local h = u32(tbl.seg.b, tbl.at + i * 4) + if h ~= 0 then + local entry = resolve(h - 1) + local w = inlineBytes(segs, entry) + for _, c in ipairs(calls(segs, entry)) do w = w + width(c, 1) end + slots[i] = w + end + end + -- the alignment the scripts themselves choose + local Script = require("src.import.Gen4Script") + local files = {} + for m = 0, scriptArchive.count - 1 do + local d = scriptArchive:get(m) + files[#files + 1] = { d = d, e = Script.entries(d) } + end + local function score(k) + local ends, total = 0, 0 + for _, f in ipairs(files) do + for _, at in ipairs(f.e) do + total = total + 1 + local pc = at + for _ = 1, 400 do + if pc + 1 > #f.d then break end + local op = f.d:byte(pc) + f.d:byte(pc + 1) * 256 + local w = slots[op + k] + if not w then break end + pc = pc + 2 + w + if Ops.TERMINATORS[op] then ends = ends + 1 break end + end + end + end + return ends, total + end + local bestK, bestS, total, second = 0, -1, 0, -1 + for k = 0, 64 do + local s, t = score(k) + total = t + if s > bestS then second = bestS; bestK, bestS = k, s elseif s > second then second = s end + end + local widths = {} + for i, w in pairs(slots) do if i - bestK >= 0 then widths[i - bestK] = w end end + return { widths = widths, offset = bestK, + table = { segment = tbl.seg.name, at = tbl.at, slots = tbl.n }, + report = { clean = bestS, entries = total, secondBest = second } } +end + +-- decode(data, at, widths) -> instructions, why it stopped +function Ops.decode(data, at, widths) + local out, pc = {}, at + for _ = 1, 2000 do + if pc + 1 > #data then return out, "overrun" end + local op = data:byte(pc) + data:byte(pc + 1) * 256 + local w = widths[op] + if not w then return out, "unknown" end + local ins = { at = pc, op = op, width = w, operands = data:sub(pc + 2, pc + 1 + w) } + if Ops.JUMPS[op] and w >= 4 then + local rel = u32(data, pc + 1 + w - 4) + if rel >= 2147483648 then rel = rel - 4294967296 end + ins.target = pc + 2 + w + rel + end + out[#out + 1] = ins + pc = pc + 2 + w + if Ops.TERMINATORS[op] then return out, "end" end + end + return out, "long" +end + +return Ops diff --git a/src/import/Gen5Text.lua b/src/import/Gen5Text.lua new file mode 100644 index 000000000..cc7f5ea9b --- /dev/null +++ b/src/import/Gen5Text.lua @@ -0,0 +1,143 @@ +-- GEN 5 TEXT (Black and White): one bank per NARC member of /a/0/0/2 (the +-- system text -- names, menus, descriptions) or /a/0/0/3 (the story text). +-- +-- A bank is +-- u16 sections, u16 entries, u32 total size, u32 (zero), u32 sectionOffset[sections] +-- and each section +-- u32 size, then per entry { u32 offset (from the section), u16 chars, u16 (flags) } +-- with the characters after the table. Black and White use one section. +-- +-- A STRING IS UTF-16 XOR A ROLLING KEY, decrypted from its LAST character back +-- to its first: the last character is the terminator 0xFFFF, so the key it was +-- encrypted with is `enc[last] ^ 0xFFFF`, and each step back rotates the key +-- right by three -- key = (key >> 3 | key << 13) & 0xFFFF. (The library the +-- cartridge uses, `GFL_STR`, decrypts the same way; pokeblack calls the +-- archive the system / script message banks.) +-- +-- Control characters: 0xFFFF ends the string, 0xFFFE breaks the line, and +-- 0xF000 opens a command -- the next u16 is the command, then a count, then +-- that many u16 arguments. 0x0100..0x01FF in a command's place mark the +-- text-variable commands (a name, a number) and are written {VARn}. + +local bit = require("bit") +local band, bor, bxor, rshift, lshift = bit.band, bit.bor, bit.bxor, bit.rshift, bit.lshift + +local T = {} + +local function u16(s, at) local a, b = s:byte(at + 1, at + 2); return (a or 0) + (b or 0) * 256 end +local function u32(s, at) return u16(s, at) + u16(s, at + 2) * 65536 end + +-- parse(bytes) -> { count, strings = { [0..count-1] = {u16...} } } or nil +function T.parse(bytes) + if type(bytes) ~= "string" or #bytes < 16 then return nil end + local sections, entries = u16(bytes, 0), u16(bytes, 2) + if sections < 1 or sections > 8 then return nil end + local base = u32(bytes, 12) -- the first section + if base + 4 > #bytes then return nil end + local out = { count = entries, codes = {} } + for i = 0, entries - 1 do + local rec = base + 4 + i * 8 + if rec + 8 > #bytes then break end + local offset, chars = u32(bytes, rec), u16(bytes, rec + 4) + local at = base + offset + if chars > 0 and at + chars * 2 <= #bytes then + local enc = {} + for c = 0, chars - 1 do enc[c] = u16(bytes, at + c * 2) end + local key = bxor(enc[chars - 1], 0xFFFF) + local dec = {} + for c = chars - 1, 0, -1 do + dec[c] = bxor(enc[c], key) + key = band(bor(rshift(key, 3), lshift(key, 13)), 0xFFFF) + end + out.codes[i] = dec + else + out.codes[i] = {} + end + end + return out +end + +local function utf8(cp) + if cp < 0x80 then return string.char(cp) end + if cp < 0x800 then return string.char(0xC0 + rshift(cp, 6), 0x80 + band(cp, 0x3F)) end + return string.char(0xE0 + rshift(cp, 12), 0x80 + band(rshift(cp, 6), 0x3F), 0x80 + band(cp, 0x3F)) +end + +-- The cartridge's private-use glyphs, as the Gen 4 port spells them. +T.GLYPHS = { [0x2486] = "PK", [0x2487] = "MN", [0x246D] = "\u{2642}", [0x246E] = "\u{2640}" } + +-- A COMPRESSED STRING (the trainer names among others): 0xF100 first, then +-- the characters as 9-bit values packed least-significant-bit first into the +-- following u16 words, 0x1FF ending it. +function T.unpack9(codes) + local out, acc, bits, n = {}, 0, 0, 0 + local i = 1 + while codes[i] ~= nil and codes[i] ~= 0xFFFF or (codes[i] == 0xFFFF and bits >= 9) do + if codes[i] ~= nil and codes[i] ~= 0xFFFF then + acc = acc + codes[i] * 2 ^ bits + bits = bits + 16 + i = i + 1 + end + while bits >= 9 do + local c = acc % 512 + acc = math.floor(acc / 512) + bits = bits - 9 + if c == 0x1FF then out[n] = 0xFFFF return out end + out[n] = c + n = n + 1 + end + if codes[i] == nil or codes[i] == 0xFFFF then break end + end + out[n] = 0xFFFF + return out +end + +-- render(codes) -> a Lua (UTF-8) string, commands as {CMD xxxx args} +function T.render(codes) + if not codes then return nil end + if codes[0] == 0xF100 then codes = T.unpack9(codes) end + local out, i = {}, 0 + local n = 0 + for k in pairs(codes) do if k + 1 > n then n = k + 1 end end + while i < n do + local c = codes[i] + if c == 0xFFFF then break + elseif c == 0xFFFE then out[#out + 1] = "\n"; i = i + 1 + elseif c == 0xF000 then + local cmd, argc = codes[i + 1] or 0, codes[i + 2] or 0 + local args = {} + for a = 1, argc do args[a] = codes[i + 2 + a] or 0 end + if cmd >= 0x0100 and cmd <= 0x01FF then + out[#out + 1] = ("{VAR%d}"):format((args[1] or 0) + 1) + elseif cmd == 0xBE00 or cmd == 0xBE01 then + -- the text box's page break (BE01 waits for a press, BE00 scrolls): + -- the engine's own paragraph break, a blank line + out[#out + 1] = "\n\n" + else + out[#out + 1] = ("{CMD %04X%s}"):format(cmd, #args > 0 and (" " .. table.concat(args, " ")) or "") + end + i = i + 3 + argc + else + out[#out + 1] = T.GLYPHS[c] or utf8(c) + i = i + 1 + end + end + return table.concat(out) +end + +-- every string of a bank, rendered: { [0..count-1] = "..." } +function T.bank(bytes) + local b = T.parse(bytes) + if not b then return nil end + local out = {} + for i = 0, b.count - 1 do out[i] = T.render(b.codes[i]) or "" end + return out, b.count +end + +-- the cache's label for a string, the Gen 4 spelling with the archive +-- (s: system, t: story/script) in front +function T.label(archive, bank, index) + return ("TEXT_G5%s_%04d_%05d"):format(archive == "story" and "T" or "S", bank, index) +end + +return T diff --git a/src/import/NdsBlz.lua b/src/import/NdsBlz.lua new file mode 100644 index 000000000..51f32e42b --- /dev/null +++ b/src/import/NdsBlz.lua @@ -0,0 +1,106 @@ +-- THE DS's BACKWARD LZ ("BLZ"), the compression a cartridge's ARM9 binary and +-- its overlays may be stored in. +-- +-- HeartGold and SoulSilver ship their ARM9 compressed; this project's Platinum +-- does not, which is why Platinum's map header table could be found in the raw +-- binary and HGSS's could not -- its 540 headers are there, packed. +-- +-- WHETHER A BINARY IS COMPRESSED IS THE CARTRIDGE'S OWN ANSWER, not a guess +-- from the bytes: the ARM9 carries the SDK's module parameters (found by the +-- magic 0xDEC00621 0x2106C0DE), whose sixth word is `compressedStaticEnd` -- +-- zero for a plain binary, the end address of the packed data otherwise. +-- +-- The format (the SDK's, as ndstool's blz.c reads it): the last eight bytes +-- are { u32 header length << 24 | compressed length, u32 bytes gained }. The +-- data decodes from the end backwards: a flag byte, then eight tokens, most +-- significant flag first -- a 0 copies one byte, a 1 reads a u16 (high byte at +-- the higher address) whose top four bits plus 3 are a length and whose low +-- twelve plus 3 are how far ABOVE the write point to copy from. Everything +-- below the compressed region is stored as it is. + +local B = {} + +local MAGIC1, MAGIC2 = 0xDEC00621, 0x2106C0DE + +local function u32(s, at) + local a, b, c, d = s:byte(at + 1, at + 4) + if not d then return nil end + return a + b * 256 + c * 65536 + d * 16777216 +end + +-- compressedEnd(arm9, ramBase) -> the file offset the packed data ends at, or nil +function B.compressedEnd(bin, ramBase) + if type(bin) ~= "string" then return nil end + local limit = math.min(#bin - 8, 0x4000) + for at = 0, limit, 4 do + if u32(bin, at) == MAGIC1 and u32(bin, at + 4) == MAGIC2 then + local endAddr = u32(bin, at - 0x1C + 0x14) + if not endAddr or endAddr == 0 then return nil end + return endAddr - (ramBase or 0x02000000) + end + end + return nil +end + +-- decompress(bytes) -> the decoded bytes, or nil plus a reason +function B.decompress(data) + local len = #data + if len < 8 then return nil, "too short for a BLZ footer" end + local word, inc = u32(data, len - 8), u32(data, len - 4) + local hdrLen = math.floor(word / 16777216) + local encLen = word % 16777216 + if hdrLen < 8 or hdrLen > 11 or encLen > len or inc == 0 then + return nil, "no BLZ footer" + end + local out = {} + for i = 1, len do out[i] = data:byte(i) end + local total = len + inc + for i = len + 1, total do out[i] = 0 end + -- 1-based positions: `src` and `dst` point one past the next byte + local src, dst = len - hdrLen + 1, total + 1 + local srcEnd = len - encLen + 1 + while src > srcEnd do + src = src - 1 + local flags = out[src] + for _ = 1, 8 do + if src <= srcEnd then break end + if flags >= 128 then + local hi = out[src - 1] + local lo = out[src - 2] + src = src - 2 + local pos = hi * 256 + lo + local n = math.floor(pos / 4096) + 3 + local disp = pos % 4096 + 3 + for _ = 1, n do + dst = dst - 1 + if dst < 1 then break end + out[dst] = out[dst + disp] + end + else + src = src - 1 + dst = dst - 1 + out[dst] = out[src] + end + flags = (flags * 2) % 256 + end + end + local parts = {} + local CHUNK = 4096 + for i = 1, total, CHUNK do + parts[#parts + 1] = string.char(unpack(out, i, math.min(i + CHUNK - 1, total))) + end + return table.concat(parts) +end + +-- The ARM9 as the game runs it: decompressed when the module parameters say +-- it is packed, as stored otherwise. +function B.arm9(bin, ramBase) + local endAt = B.compressedEnd(bin, ramBase) + if not endAt or endAt > #bin or endAt < 8 then return bin end + local packed = bin:sub(1, endAt) + local out = B.decompress(packed) + if not out then return bin end + return out .. bin:sub(endAt + 1) +end + +return B diff --git a/src/import/NdsRom.lua b/src/import/NdsRom.lua index 2fee1d9da..71df0a964 100644 --- a/src/import/NdsRom.lua +++ b/src/import/NdsRom.lua @@ -271,8 +271,16 @@ end -- The ARM9 binary, which is where the tables that are NOT in the filesystem -- live -- and in Gen 4 that is a great many of them. +-- +-- DECOMPRESSED WHEN IT IS STORED PACKED (HeartGold and SoulSilver; see +-- NdsBlz), once per open ROM, so every reader sees the binary the game runs. function NdsRom:arm9() - return readAt(self, self:u32(H.ARM9_ROM) or 0, self:u32(H.ARM9_SIZE) or 0) + if self._arm9 then return self._arm9 end + local raw = readAt(self, self:u32(H.ARM9_ROM) or 0, self:u32(H.ARM9_SIZE) or 0) + local ok, Blz = pcall(require, "src.import.NdsBlz") + if ok and raw then raw = Blz.arm9(raw, self:u32(H.ARM9_RAM) or 0x02000000) end + self._arm9 = raw + return raw end -- One ARM9 overlay's bytes, by overlay id. The overlay table entry is 32 @@ -286,7 +294,17 @@ function NdsRom:overlay(index) if not (at and index and index >= 0 and index < n) then return nil end local fileId = self:u32(at + index * 32 + 0x18) if not fileId then return nil end - return self:readId(fileId), { + -- COMPRESSED WHEN THE ENTRY SAYS SO: bit 24 of the flags word at +0x1C is + -- the SDK's "this overlay is BLZ-packed" (its low 24 bits the packed size). + -- 127 of HeartGold's 129 overlays carry it and none of Platinum's 122, so a + -- reader scanning for a table in Platinum sees exactly what it always did. + local flags = self:u32(at + index * 32 + 0x1C) or 0 + local bytes = self:readId(fileId) + if bytes and math.floor(flags / 16777216) % 2 == 1 then + local unpacked = require("src.import.NdsBlz").decompress(bytes) + if unpacked then bytes = unpacked end + end + return bytes, { ram = self:u32(at + index * 32 + 0x04), size = self:u32(at + index * 32 + 0x08), fileId = fileId, diff --git a/src/import/RomExtractorGen4.lua b/src/import/RomExtractorGen4.lua index b4c06f870..271354b28 100644 --- a/src/import/RomExtractorGen4.lua +++ b/src/import/RomExtractorGen4.lua @@ -250,6 +250,9 @@ RomExtractorGen4.STAGES = STAGES function RomExtractorGen4.new(romPath, version, manifest, progress) local rom, err = NdsRom.open(romPath) if not rom then return nil, err end + -- HeartGold and SoulSilver: their paths and banks over Platinum's, or + -- Platinum's put back (src/import/Gen4Versions.lua) + require("src.import.Gen4Versions").apply(version, PATH, BANK) return setmetatable({ rom = rom, version = version, manifest = manifest, progress = progress, stage = 0, wrote = {}, @@ -583,6 +586,8 @@ function RomExtractorGen4:extractParticles() local saved = self:saveImage(name, image, { format = texture.formatName, colours = #texture.palette, + flipS = texture.flipS or nil, + flipT = texture.flipT or nil, }) if saved then entry.textures[#entry.textures + 1] = saved @@ -791,7 +796,8 @@ end function RomExtractorGen4:extractEncounters() self:beginStage("encounters") - local out = Gen4Encounters.all(assert(self:archive("encounters"))) + local out = Gen4Encounters.all(assert(self:archive("encounters")), + require("src.import.Gen4Versions").family(self.version)) self:write("encounters", out) return out end @@ -859,7 +865,7 @@ function RomExtractorGen4:extractTrainerMessages() local out, report = Gen4TrainerMessages.all(records:get(0), offsets:get(0)) -- The third reading, taken here because this is the only place that has the -- message bank open at the same time as the table. - local bank = self:bank(Gen4TrainerMessages.BANK) + local bank = self:bank(BANK.trainerMessages or Gen4TrainerMessages.BANK) report.bankEntries = bank and bank.count or 0 self.trainerMessageReport = report self:write("gen4_trainer_messages", out) @@ -888,11 +894,21 @@ function RomExtractorGen4:extractMaps() events = assert(self:archive("events")).count, names = nameCount, } + -- HeartGold and SoulSilver lay the same 24 bytes out differently, and say + -- how many maps there are in the name table (one 16-byte name per header) + local family = require("src.import.Gen4Versions").family(self.version) + local parse = Gen4MapHeaders.parserFor(family) + if family == "hgss" then + limits.parse = parse + limits.count = nameCount + -- the place-name bank, not the map-name table, bounds HGSS's mapsec + limits.names = 256 + end local arm9 = assert(self.rom:arm9()) local at, count = Gen4MapHeaders.find(arm9, limits) if not at then return nil, "map header table not found in the ARM9" end - local headers = Gen4MapHeaders.all(arm9, at, count) + local headers = Gen4MapHeaders.all(arm9, at, count, parse) for id, h in pairs(headers) do h.internalName = names[id] h.label = self:string(BANK.mapLabel, h.labelText) @@ -1059,6 +1075,9 @@ function RomExtractorGen4:extractMaps() -- CAMERA_TYPE_INTERIOR_ORTHOGRAPHIC, 189 are DEFAULT and 60 are CAVE; -- see `src/render/Gen4Camera.lua` for the table they index. cameraType = h.camera, + -- whose table that number indexes (Gen4Camera.HGSS_TYPES for HGSS); + -- absent for Platinum, whose maps read the table they always have + cameraFamily = h.family, -- WHICH BATTLE BACKGROUND A FIGHT ON THIS MAP USES. -- -- `MapHeader_GetBattleBG` is the whole of the cartridge's answer: @@ -1256,7 +1275,7 @@ function RomExtractorGen4:extractRegions(events, names) index = i, localId = o.localId, graphicsId = o.graphics, sprite = spriteKey, - spriteName = Gen4ObjectGfx.name(o.graphics), + spriteName = self:objectSpriteName(o.graphics), noSprite = noSprite, -- ...and, when the reason is that it is a MODEL, which one. A -- member of fldeff.narc, not of the sprite archive: the two are @@ -1291,6 +1310,9 @@ function RomExtractorGen4:extractRegions(events, names) end)(), scriptBand = band, berryPatch = band == 'berry_tree_interactions' and o.data[1] or nil, + -- HGSS's Apricorn trees (scr_seq_0150's band): the tree's index into + -- sTreeApricorns is param 0 (apricorn_tree_sys.c) + apricornTree = (band == 'apricorn_tree' and o.data) and o.data[1] or nil, scriptEntry = (kind == "band" or kind == "map") and entry or nil, scriptFile = member, movementType = o.movementType, @@ -1481,11 +1503,18 @@ function RomExtractorGen4:extractScripts() -- The instruction's own width, asked for rather than written down: the -- movement list is at `(the byte after the instruction) + offset`, so a -- change to the opcode table must not be able to silently move it. + -- + -- HEARTGOLD AND SOULSILVER decode with their own command table and are then + -- renamed into Platinum's words (Gen4HgssScriptAliases), so the VM lowers + -- one vocabulary. + local hgss = require("src.import.Gen4Versions").family(self.version) == "hgss" + local ops = hgss and require("src.import.Gen4HgssScriptOps") or Gen4ScriptOps + local Aliases = hgss and require("src.import.Gen4HgssScriptAliases") or nil local moveOp - for op, entry in pairs(Gen4ScriptOps.COMMANDS) do + for op, entry in pairs(ops.COMMANDS) do if entry[1] == "applymovement" then moveOp = op end end - local moveSize = moveOp and Gen4ScriptOps.size(moveOp) + local moveSize = moveOp and ops.size(moveOp) local movesRead, movesRefused = 0, 0 for m = 0, arc.count - 1 do local bytes = arc:get(m) @@ -1500,7 +1529,7 @@ function RomExtractorGen4:extractScripts() local i = 1 while i <= #queue do local at = queue[i]; i = i + 1 - local instructions, stopped = Gen4Script.decode(bytes, at) + local instructions, stopped = Gen4Script.decode(bytes, at, hgss and ops or nil) scripts[Gen4ScriptVM.label(m, at)] = { instructions = instructions, stopped = stopped } for _, ins in ipairs(instructions) do @@ -1523,6 +1552,7 @@ function RomExtractorGen4:extractScripts() movesRefused = movesRefused + 1 end end + if Aliases then Aliases.apply(ins) end end end -- The ENTRY POINTS in order: a script id in an event is an index into @@ -1779,14 +1809,17 @@ end -- `message 3` is entry 3 of bank 213 and has nothing to do with the town -- the player is standing in. function RomExtractorGen4:scriptBands() - local names = Gen4Archives.names(PATH.scripts) - if not names then return nil end + -- HeartGold and SoulSilver name each band's file by NUMBER (their table's + -- third field); Platinum's are names looked up in the archive's list + local hgss = Gen4ScriptBands.family == "hgss" + local names = not hgss and Gen4Archives.names(PATH.scripts) + if not hgss and not names then return nil end local memberOf = {} - for index, name in ipairs(names) do memberOf[name] = index - 1 end + for index, name in ipairs(names or {}) do memberOf[name] = index - 1 end local out, resolved, missing = {}, 0, 0 for _, band in ipairs(Gen4ScriptBands.BANDS) do - local member = memberOf[band[3]] + local member = hgss and band[3] or memberOf[band[3]] local entries = member and (self._entries or {})[member] if entries then out[band[2]] = { @@ -3056,7 +3089,9 @@ function RomExtractorGen4:extractFonts() return index end + local V = require("src.import.Gen4Versions") local names = Gen4Archives.names(PATH.fonts) + or (V.family(self.version) == "hgss" and V.MEMBER_NAMES.fonts or nil) for member = 0, arc.count - 1 do local name = names and names[member + 1] -- NFGR carries no magic, so it is identified by its own arithmetic: the @@ -3857,6 +3892,14 @@ end -- The graphicsId -> member table, read once from the cartridge. function RomExtractorGen4:objectGfxTable() if self._objectGfx ~= nil then return self._objectGfx end + -- HEARTGOLD AND SOULSILVER: pret's own table (ov01_022074A8), generated + -- into Gen4HgssObjectGfx, rather than Platinum's overlay-5 scan + if require("src.import.Gen4Versions").family(self.version) == "hgss" then + local map = {} + for id, row in pairs(require("src.import.Gen4HgssObjectGfx").byId) do map[id] = row[1] end + self._objectGfx = map + return map + end local overlay = self.rom and self.rom:overlay(Gen4ObjectGfx.OVERLAY) local members = self:archive("overworld") local map = overlay and Gen4ObjectGfx.read(overlay, members and members.count or 470) @@ -3870,6 +3913,11 @@ end -- is the whole content of the answer. function RomExtractorGen4:objectModelTable() if self._objectModels ~= nil then return self._objectModels end + -- HGSS's 3D object models (signs, mailboxes) are not identified yet + if require("src.import.Gen4Versions").family(self.version) == "hgss" then + self._objectModels = false + return false + end local overlay = self.rom and self.rom:overlay(Gen4ObjectGfx.OVERLAY) local map = overlay and Gen4ObjectGfx.readModels(overlay, Gen4ObjectGfx.MODEL_MEMBERS) @@ -3886,6 +3934,21 @@ end -- spriteFor(graphicsId) -> key, member, reason -- `reason` is set when the id has no billboard of its own rather than when the -- lookup failed; the two must not look alike in the index. +-- An object's graphics name, for logs and tools. HGSS numbers its sprites +-- its own way (pokeheartgold constants/sprites.h, carried by +-- Gen4HgssObjectGfx), so Platinum's list named Route 30's apricorn trees +-- "wall_blocking_rotoms_room"; the var range 101..117 is var_(id - 101) on +-- both, as map_object.c resolves it. +function RomExtractorGen4:objectSpriteName(id) + if require("src.import.Gen4Versions").family(self.version) == "hgss" then + local entry = require("src.import.Gen4HgssObjectGfx").byId[id] + if entry then return entry[2] end + if id and id >= 101 and id <= 117 then return ("var_%d"):format(id - 101) end + return nil + end + return Gen4ObjectGfx.name(id) +end + function RomExtractorGen4:spriteFor(graphicsId) local map = self:objectGfxTable() local member = map and map[graphicsId] @@ -3961,7 +4024,9 @@ function RomExtractorGen4:extractOverworld() -- -- The real order is in this same archive -- see Gen4Facings -- and the -- twenty-five tables at its tail are read here once. - local sequences, seqReport = Gen4Facings.read(arc) + -- HGSS keeps its sequence tables mid-archive (Gen4HgssFacings) + local hgssSeq = require("src.import.Gen4Versions").family(self.version) == "hgss" + local sequences, seqReport = (hgssSeq and require("src.import.Gen4HgssFacings") or Gen4Facings).read(arc) local facingsReport = { byName = 0, byCount = 0, none = 0, sequences = seqReport } @@ -4227,8 +4292,10 @@ function RomExtractorGen4:extractTrainerSprites() local total = Gen4Trgra.classCount(arc) local written, animated, byClass = 0, 0, {} + -- HGSS's trainer pictures are scrambled in Diamond and Pearl's order + local dpOrder = require("src.import.Gen4Versions").family(self.version) == "hgss" for class = 0, total - 1 do - local sheet = Gen4Trgra.sheet(arc, Gen4Graphics, class) + local sheet = Gen4Trgra.sheet(arc, Gen4Graphics, class, dpOrder) local palette = Gen4Trgra.palette(arc, Gen4Graphics, class) local name = nil if names and class < names.count then @@ -4504,9 +4571,18 @@ function RomExtractorGen4:extractField() local headers = self._mapHeaders local maps = self._mapDefs - local start, respawn, at, spawns, spawnAt = - Gen4Field.find(self.rom and self.rom:arm9(), headers, maps, - headers and self._mapHeaderCount or nil) + -- HeartGold and SoulSilver: their own Location pair and 18-byte spawn rows + -- (Gen4HgssField), read into the same fields + local hgssBoot = require("src.import.Gen4Versions").family(self.version) == "hgss" + local start, respawn, at, spawns, spawnAt + if hgssBoot then + start, respawn, at, spawns, spawnAt = require("src.import.Gen4HgssField").find( + self.rom and self.rom:arm9(), self._mapHeaderCount or 540) + else + start, respawn, at, spawns, spawnAt = + Gen4Field.find(self.rom and self.rom:arm9(), headers, maps, + headers and self._mapHeaderCount or nil) + end local out = { source = "ROM:Platinum" } if not start then @@ -4593,10 +4669,15 @@ function RomExtractorGen4:extractField() -- Found by NAME rather than by graphics id. `player_m` and `player_f` are -- what the object-graphics table calls them, and a name cannot be off by one -- the way an id can -- which is exactly the mistake this is fixing. - out.playerSprites, out.playerForms = self:playerSheets() + if hgssBoot then + out.playerSprites, out.playerForms = require("src.import.Gen4HgssField").playerSheets( + function(id) return (self:spriteFor(id)) end) + else + out.playerSprites, out.playerForms = self:playerSheets() + end - out.source = ("ROM:Platinum (locations arm9+0x%X, spawns arm9+0x%X)") - :format(at, spawnAt) + out.source = ("ROM:%s (locations arm9+0x%X, spawns arm9+0x%X)") + :format(hgssBoot and self.version or "Platinum", at, spawnAt) self:write("field", out) self.fieldReport = { found = true, startMap = startMap, startX = start.x, startY = start.z, @@ -5476,7 +5557,10 @@ function RomExtractorGen4:extractTerrain(names) local land = self:archive("land") local matrices = self:archive("matrices") local areaArc = self:archive("areaData") - local texArc = self:archiveAt("/fielddata/areadata/area_map_tex/map_tex_set.narc") + -- HeartGold and SoulSilver: the numbered texture-set archive (/a/0/4/4) and + -- their own area record (Gen4HgssArea); everything else here is shared. + local hgssField = require("src.import.Gen4Versions").family(self.version) == "hgss" + local texArc = self:archiveAt(PATH.mapTextures or "/fielddata/areadata/area_map_tex/map_tex_set.narc") local out = { source = "ROM:land_data.narc + map_matrix.narc + map_tex_set.narc", chunkTiles = Gen4Terrain.CHUNK_TILES, @@ -5586,7 +5670,8 @@ function RomExtractorGen4:extractTerrain(names) for id, h in pairs(self._mapHeaders or {}) do local mapId = names and names[id] local record = areaArc and areaArc:get(h.areaData) - local area = record and Gen4Maps.areaData(record) + local area = record and (hgssField and require("src.import.Gen4HgssArea").parse(record) + or Gen4Maps.areaData(record)) if mapId and area then out.maps[mapId] = { texture = area.mapTexture, area = h.areaData, -- `areaLight`, not the old `lighting`: that name was on @@ -5605,6 +5690,21 @@ function RomExtractorGen4:extractTerrain(names) end end + -- THE REST IS PLATINUM'S, by path and by table, and HGSS skips it until + -- each is ported (Gen4Versions STAGES says which stages HGSS runs). + if hgssField then + -- /data/arealight.narc is byte for byte Platinum's in HGSS (the same four + -- files), and HGSS's area record already names the member through its own + -- light mapping (Gen4HgssArea), so the light stage runs as it is + self:areaLights(out.maps) + -- the Headbutt tutor and the Frontier's three (Gen4MoveTutor.HGSS) + self:moveTutor() + self:write("gen4_terrain", out) + self.terrainReport = { chunks = packed, refused = refused, sets = setsBuilt, + pairedShapes = pairedShapes, undecoded = undecoded, maps = attributed, + noArea = noArea, geometry = #geometry, heights = #heights } + return out + end self:areaLights(out.maps) self:mapProps() self:feebas() @@ -5905,8 +6005,9 @@ function RomExtractorGen4:endingArt() self:write("gen4_ending", E.data(self.rom)) end --- THE POKETCH APPS' SPRITES AND TILES (src/import/Gen4PoketchArt.lua) AND THE --- EVOLUTION SCENE'S PARTICLES (src/import/Gen4EvolutionArt.lua). +-- THE POKETCH APPS' SPRITES AND TILES (src/import/Gen4PoketchArt.lua), THE +-- EVOLUTION SCENE'S PARTICLES (src/import/Gen4EvolutionArt.lua) AND THE START +-- MENU'S ICONS (src/import/Gen4StartMenuArt.lua). -- (Literal module names, for tools/gen4_cache_wiring_check.lua.) function RomExtractorGen4:poketchEvolutionArt() if not self.rom then return end @@ -5929,6 +6030,12 @@ function RomExtractorGen4:poketchEvolutionArt() self:write("gen4_evolution_art", save("evolution_art", evolution)) self:write("gen4_evolution_ink", E.data(self.rom)) end + local SM = require("src.import.Gen4StartMenuArt") + local startMenu = SM.images(self.rom) + if startMenu then + self:write("gen4_start_menu_art", save("start_menu_art", startMenu)) + self:write("gen4_start_menu_ink", SM.data(self.rom)) + end end -- THE POFFIN COOKING, CASE AND ICONS -- see src/import/Gen4PoffinArt.lua. @@ -6103,6 +6210,19 @@ end function RomExtractorGen4:townMap() local out = self.rom and require("src.import.Gen4TownMap").extract(self.rom) if out then self:write("gen4_town_map", out) end + -- the bottom screen (Gen4TownMapArt); literal module names for + -- tools/gen4_cache_wiring_check.lua + if not self.rom then return end + local Art = require("src.import.Gen4TownMapArt") + local images = Art.images(self.rom) + if images then + local index = {} + for key, image in pairs(images) do + index[key] = self:saveImage("town_map_art/" .. key, image, { originX = image.originX, originY = image.originY }) + end + self:write("gen4_town_map_art", index) + self:write("gen4_town_map_ink", Art.data(self.rom)) + end end function RomExtractorGen4:specialEncounters() @@ -6226,6 +6346,21 @@ local MODEL_ARCHIVES = { label = "field-effect models" }, } +-- HEARTGOLD AND SOULSILVER keep their buildings in TWO archives, and the area +-- record says which a map loads (Gen4HgssArea: +6 nonzero opens /a/0/4/0, the +-- outdoor ones, zero /a/1/4/8, the rooms'). Both are written; Gen4Ground picks +-- by the map's `outdoors`. 339 of 340 and 219 of 222 carry their own textures, +-- as Platinum's buildings do. The other Platinum archives above are not HGSS +-- paths and wait to be identified. +local HGSS_MODEL_ARCHIVES = { + { path = "/a/0/4/0", out = "buildings", label = "map buildings (outdoor)" }, + { path = "/a/1/4/8", out = "interiorBuildings", label = "map buildings (rooms)" }, + -- choose_starter_main_res: Elm's turntable -- tabletop, turntable, the + -- three balls and their glow, with the balls' rock / open and the + -- turntable's animations (choose_starter_app.c loads members 0..7) + { path = "/a/0/8/2", out = "hgssStarter", label = "starter turntable" }, +} + -- Platinum's 3D, in the shape the engine can load: packed geometry, the -- textures as ordinary PNGs, and the animations that drive each model. -- @@ -6368,7 +6503,8 @@ function RomExtractorGen4:extractModels() local animFailed = 0 local patternWritten = 0 - for _, entry in ipairs(MODEL_ARCHIVES) do + local hgssModels = require("src.import.Gen4Versions").family(self.version) == "hgss" + for _, entry in ipairs(hgssModels and HGSS_MODEL_ARCHIVES or MODEL_ARCHIVES) do local arc = self:archiveAt(entry.path) local set = { label = entry.label, path = entry.path, models = {}, animations = {} } @@ -6725,7 +6861,77 @@ function RomExtractorGen4:extractModels() return out end +-- The stages a version runs, by name (Gen4Versions.STAGES). Platinum runs +-- every one below; HeartGold and SoulSilver run those whose formats have been +-- checked against their cartridge, and the rest are written to the report as +-- not ported rather than run against data they would misread. +RomExtractorGen4.STAGE_METHODS = { + text = "extractText", species = "extractSpecies", moves = "extractMoves", items = "extractItems", + trainers = "extractTrainers", gen4_trainer_messages = "extractTrainerMessages", + encounters = "extractEncounters", scripts = "extractScripts", +} +-- the stages that hand each other what they found, as run() chains them +RomExtractorGen4.STAGE_CHAIN = { + maps = function(self) self._stageHeaders, self._stageNames = self:extractMaps() end, + events = function(self) self._stageEvents = self:extractEvents() end, + regions = function(self) self:extractRegions(self._stageEvents, self._stageNames) end, + link = function(self) self:linkScripts(self._stageHeaders, self._stageEvents, self._stageNames) end, + terrain = function(self) self:extractTerrain(self._stageNames) end, + models = function(self) self:extractModels() end, + overworld = function(self) self:extractOverworld() end, + field = function(self) self:extractField() end, + -- the frames before the fonts: writeFontDef carries `self._winFrames` + frames = function(self) self:extractWindowFrames() end, + -- no ROM input: the stand-in tileset MapLoader needs for every Gen 4 map + tilesets = function(self) self:extractTilesets() end, + -- HGSS's battle pictures so far: the backdrops (Gen4HgssBattle) and the + -- window frames, in the `gen4_graphics` shape the battle screen reads + battle_graphics = function(self) + self:beginStage("graphics") + local backgrounds, written, skipped = require("src.import.Gen4HgssBattle").backgrounds(self) + self:write("gen4_graphics", { backgrounds = backgrounds, terrain = {}, battleObjects = {}, + screens = {}, palettes = {}, skipped = skipped, frames = self._winFrames }) + self.battleGraphicsReport = { backgrounds = written, skipped = #skipped } + end, + species_sprites = function(self) self:extractSpeciesSprites() end, + trainer_sprites = function(self) self:extractTrainerSprites() end, + heights = function(self) self:extractHeights() end, + -- HGSS's constants from their own module (Platinum's stage reads Sinnoh-only + -- archives -- the Poketch, the Underground, Honey trees) + constants = function(self) + self:beginStage("constants") + local constants, chart, report = require("src.import.Gen4HgssConstants").build(self) + if chart then self:write("type_chart", chart) end + self:write("constants", constants) + self.constantsReport = report + end, + fonts = function(self) self:extractFonts() end, +} +function RomExtractorGen4:runStages(list) + local skipped = {} + for _, name in ipairs(list) do + local chained = RomExtractorGen4.STAGE_CHAIN[name] + if chained then chained(self) + else + local method = RomExtractorGen4.STAGE_METHODS[name] + self[method](self) + end + end + for _, name in ipairs(STAGES) do + local listed = false + for _, n in ipairs(list) do if n == name then listed = true end end + if not listed then skipped[#skipped + 1] = name end + end + self.notPorted = skipped + self:write("gen4_version", { version = self.version, family = require("src.import.Gen4Versions").family(self.version), + stages = list, notPorted = skipped }) + return self.wrote +end + function RomExtractorGen4:run() + local V = require("src.import.Gen4Versions") + local only = V.STAGES[V.family(self.version)] + if only then return self:runStages(only) end self:extractText() self:extractSpecies() self:extractMoves() diff --git a/src/import/RomExtractorGen5.lua b/src/import/RomExtractorGen5.lua new file mode 100644 index 000000000..38d2c7a65 --- /dev/null +++ b/src/import/RomExtractorGen5.lua @@ -0,0 +1,524 @@ +-- BLACK AND WHITE OUT OF THE CARTRIDGE (generation 5). +-- +-- A DS cartridge, like Platinum's: a filesystem the extractor opens itself +-- (RomImporter hands it a path), every archive a NARC under /a/x/y/z. Each +-- archive below was IDENTIFIED BY LOOKING -- member counts, record sizes and +-- the records themselves -- because pokeblack names none of them yet: +-- +-- /a/0/0/2 system text, 288 banks (species 70, moves 203, items 54 ...) +-- /a/0/0/3 story text, 472 banks +-- /a/0/1/6 personal data, 669 x 0x3C (the 649 species and their forms) +-- /a/0/1/8 learnsets, 668 /a/0/1/9 evolutions, 668 x 42 +-- /a/0/2/1 moves, 560 x 0x24 /a/0/2/4 items, 627 x 0x24 +-- /a/0/9/2 trainers, 616 x 0x14 /a/0/9/3 their parties +-- +-- The text banks were found the same way: bank 70 begins "Bulbasaur", +-- "Ivysaur", and its 495th is "Snivy"; 203 begins "Pound"; 54 "Master Ball"; +-- 24 "Hardy"; 199 "Normal"; 191 "PKMN Trainer", "Youngster"; 190 the trainer +-- names (stored 9-bit compressed); 89 the places ("Nuvema Town" at 4); 235 +-- the dex entries; 260 the categories ("Seed Pokemon"); 53 and 202 the item +-- and move descriptions. +-- +-- WHAT RUNS TODAY is the data the battle and the menus need: text, species +-- (with learnsets and evolutions), moves, items and trainers. Maps, events, +-- scripts, encounters and every piece of art are Gen 5 formats this port has +-- not read yet, and are written to the report as not ported rather than +-- guessed at. The field will render through the Gen 4 3D ground so that it +-- shares Platinum's camera and its controls, and the second screen comes from +-- GameVersion.isDualScreen -- neither needs anything from this file. +-- +-- Black and White share every path and bank here; the version exclusives are +-- in the data itself. + +local NdsRom = require("src.import.NdsRom") +local Narc = require("src.import.NarcArchive") +local Text = require("src.import.Gen5Text") +local D = require("src.import.Gen5Data") +local LuaWriter = require("src.import.LuaWriter") + +local X = {} +X.__index = X + +X.PATH = { + systemText = "/a/0/0/2", storyText = "/a/0/0/3", + personal = "/a/0/1/6", learnsets = "/a/0/1/8", evolutions = "/a/0/1/9", + moves = "/a/0/2/1", items = "/a/0/2/4", + trainerData = "/a/0/9/2", trainerParty = "/a/0/9/3", +} +X.BANK = { + species = 70, speciesUpper = 284, move = 203, moveUpper = 286, moveDescription = 202, + item = 54, itemDescription = 53, nature = 24, typeName = 199, typeUpper = 287, + trainer = 190, trainerClass = 191, mapLabel = 89, dexEntry = 235, dexCategory = 260, +} +X.STAGES = { "text", "species", "moves", "items", "trainers", "maps", "field", "constants", "encounters", "fonts", "scripts" } +X.ALL_STAGES = { "text", "species", "moves", "items", "trainers", "abilities", "encounters", "maps", "events", + "scripts", "graphics", "fonts", "overworld", "species_sprites", "trainer_sprites", "cries", "models", "field" } +X.SPECIES_COUNT = 649 + +-- what a finished Gen 5 import has written (RomImporter.requiredFiles) +X.REQUIRED_FILES = { + "data/generated/text.lua", "data/generated/text_pointers.lua", "data/generated/pokemon.lua", + "data/generated/moves.lua", "data/generated/items.lua", "data/generated/trainers.lua", + "data/generated/gen5_version.lua", +} + +function X.new(romPath, version, manifest, progress) + local rom, err = NdsRom.open(romPath) + if not rom then return nil, err end + return setmetatable({ rom = rom, version = version, manifest = manifest, progress = progress, + stage = 0, wrote = {}, _archives = {}, _banks = {} }, X) +end + +function X:close() if self.rom then self.rom:close() end self.rom = nil end + +function X:beginStage(name) + self.stage = self.stage + 1 + if self.progress then self.progress(self.stage - 1, #X.STAGES, name, 0, 1) end +end + +function X:write(name, value) + LuaWriter.write("data/generated/" .. name .. ".lua", value) + self.wrote[#self.wrote + 1] = name +end + +function X:archive(key) + if self._archives[key] then return self._archives[key] end + local path = X.PATH[key] + local bytes = path and self.rom:read(path) + if not bytes then return nil, ("no %s in the cartridge"):format(tostring(path)) end + local arc = Narc.parse(bytes) + self._archives[key] = arc + return arc +end + +function X:bank(n) + if self._banks[n] ~= nil then return self._banks[n] end + local arc = self:archive("systemText") + local b = arc and Text.bank(arc:get(n)) or false + self._banks[n] = b + return b or nil +end +function X:string(bankId, index) + local b = self:bank(bankId) + return b and b[index] +end + +-- every bank of both archives, and the flat labels the engine reads +function X:extractText() + self:beginStage("text") + local out, flat, strings = { system = {}, story = {} }, {}, 0 + for _, which in ipairs({ "system", "story" }) do + local arc = assert(self:archive(which .. "Text")) + for b = 0, arc.count - 1 do + local bank = Text.bank(arc:get(b)) + if bank then + out[which][b] = bank + for i, s in pairs(bank) do + flat[Text.label(which, b, i)] = s + -- a map script's lines are its zone's STORY bank, looked up by the + -- Gen 4 script engine as TEXT_B_ -- so the story banks + -- answer to that spelling too + if which == "story" then flat[("TEXT_B%04d_%05d"):format(b, i)] = s end + strings = strings + 1 + end + end + end + end + self:write("gen5_text", out) + self:write("text", flat) + self:write("text_pointers", {}) + self.textReport = { strings = strings } + return out +end + +function X:extractSpecies() + self:beginStage("species") + local personal = assert(self:archive("personal")) + local learn = assert(self:archive("learnsets")) + local evo = assert(self:archive("evolutions")) + local out = {} + for id = 0, X.SPECIES_COUNT do + local p = D.personal(personal:get(id)) + if p then + p.id, p.dex = id, id + p.name = self:string(X.BANK.species, id) + p.learnset = D.learnset(learn:get(id)) + p.evolutions = D.evolutions(evo:get(id)) + p.dexEntry = self:string(X.BANK.dexEntry, id) + p.category = self:string(X.BANK.dexCategory, id) + out[id] = p + end + end + -- the extra forms past the species, by index (Gen 5's form table) + local forms = {} + for id = X.SPECIES_COUNT + 1, personal.count - 1 do forms[id] = D.personal(personal:get(id)) end + self:write("pokemon", out) + self:write("gen5_forms", forms) + self._pokemon = out + return out +end + +function X:extractMoves() + self:beginStage("moves") + local arc = assert(self:archive("moves")) + local out = {} + for id = 0, arc.count - 1 do + local m = D.move(arc:get(id)) + if m then + m.id = id + m.name = self:string(X.BANK.move, id) + m.description = self:string(X.BANK.moveDescription, id) + out[id] = m + end + end + self:write("moves", out) + return out +end + +function X:extractItems() + self:beginStage("items") + local arc = assert(self:archive("items")) + local out = {} + for id = 0, arc.count - 1 do + local it = D.item(arc:get(id)) + if it then + it.id = id + it.name = self:string(X.BANK.item, id) + it.description = self:string(X.BANK.itemDescription, id) + out[id] = it + end + end + -- THE MACHINES' MOVES: a flat u16 table in the ARM9 (0x9AAA0 in Black), + -- TM01..TM92, HM01..HM06, TM93..TM95, then 0 -- the order of the item ids + -- 328..419, 420..425 and 618..620. Found by its first five moves (Hone + -- Claws, Dragon Claw, Psyshock, Calm Mind, Roar). Stamped as `machine`, + -- the record the TM name buffer and the bag read (Platinum's one run of + -- ids does not hold here, so ItemEffects' join cannot place them). + local arm9 = self.rom:arm9() + local at = arm9 and arm9:find(string.char(0xD4, 0x01, 0x51, 0x01, 0xD9, 0x01, 0x5B, 0x01, 0x2E, 0x00), 1, true) + if at then + local ids = {} + for id = 328, 425 do ids[#ids + 1] = id end + for id = 618, 620 do ids[#ids + 1] = id end + for k, id in ipairs(ids) do + local o = at + (k - 1) * 2 + local move = arm9:byte(o) + arm9:byte(o + 1) * 256 + if move > 0 and out[id] then + out[id].machine = { move = move, index = k - 1, hm = (k > 92 and k <= 98) or nil } + end + end + end + self:write("items", out) + return out +end + +function X:extractTrainers() + self:beginStage("trainers") + local data, party = assert(self:archive("trainerData")), assert(self:archive("trainerParty")) + local out = {} + for id = 0, data.count - 1 do + local tr = D.trainer(data:get(id)) + if tr then + tr.id = id + tr.name = self:string(X.BANK.trainer, id) + tr.className = self:string(X.BANK.trainerClass, tr.class) + tr.party = D.party(party:get(id), tr) + out[id] = tr + end + end + self:write("trainers", out) + -- WHAT EACH TRAINER SAYS: Platinum's two-file index in the same shape -- + -- /a/0/9/0 the flat { u16 trainer, u16 type } records (1,762, one per line + -- of system bank 189) and /a/0/9/1 one u16 byte offset per trainer (616). + -- Gen4TrainerMessages' walk reaches all 1,762 with no duplicates; types + -- 0 / 1 / 2 are the challenge, the defeat and the after-battle line as on + -- Platinum (Waiter Maxwell: "Welcome to Striaton Gym!", "Yow!", ...). + local Narc = require("src.import.NarcArchive") + local tblBytes, ofsBytes = self.rom:read("/a/0/9/0"), self.rom:read("/a/0/9/1") + local tbl, ofs = tblBytes and Narc.parse(tblBytes), ofsBytes and Narc.parse(ofsBytes) + if tbl and ofs then + local index = require("src.import.Gen4TrainerMessages").all(tbl:get(0), ofs:get(0)) + self:write("gen4_trainer_messages", index) + end + return out +end + +-- Pictures and side-car binaries go where Gen 4's do (CacheFs, under the +-- version's own prefix), so the Gen 4 renderer finds them by the same paths. +function X:saveImage(relative, image, extra) + if not image then return nil end + local ImageWriter = require("src.import.ImageWriter") + local data = ImageWriter.fromRGBA8(image.width, image.height, image.rgba) + if not data then return nil end + local path = "assets/generated/gen4/" .. relative .. ".png" + ImageWriter.save(data, path) + local entry = { path = path, width = image.width, height = image.height } + for k, v in pairs(extra or {}) do entry[k] = v end + return entry +end + +function X:saveBinary(relative, bytes) + local path = "assets/generated/gen4/" .. relative + local ok, why = require("src.import.CacheFs").write(path, bytes) + if not ok then error("could not write " .. path .. ": " .. tostring(why)) end + return path +end + +-- THE FIELD, as Gen4Ground reads it (src/import/Gen5Field.lua): zones as map +-- headers, the matrices, every chunk's model and permissions, the texture +-- sets, and a map def per zone -- plus the stand-in tileset and an empty +-- script pool, which the Gen 4 loader requires of a cache of this shape. +function X:extractField() + self:beginStage("field") + local out, report = require("src.import.Gen5Field").build(self, { + image = function(rel, image, extra) return self:saveImage(rel, image, extra) end, + binary = function(rel, bytes) return self:saveBinary(rel, bytes) end, + }) + self:write("gen4_map_headers", out.headers) + self:write("gen4_map_matrices", out.matrices) + self:write("gen4_map_permissions", out.permissions) + self:write("map_layouts", out.layouts) + self:write("maps", out.maps) + self:write("gen5_rails", out.rails or {}) + self:write("gen4_terrain", out.terrain) + self:write("gen5_zones", out.zones) + self:write("gen4_models", out.models) + self:write("sprites", out.sprites) + self:write("gen5_sprite_table", out.spriteTable) + -- the player: sprite id 1 is Hilbert (32 frames, walk then run); the field + -- record says which sheet the player wears, as Platinum's does + local hero = out.spriteTable[1] and ("SPRITE_G4_%03d"):format(out.spriteTable[1]) + if hero and out.sprites[hero] then + -- WHERE A NEW GAME STARTS. The new-game location setter (ARM9 0x2014204) + -- is called with zone 391, the player's room, and takes the standing + -- spot from that zone's header (+0x24 / +0x2C, Gen5Maps' spawnX / Z) with + -- facing 2 -- "left" in Gen 4's order (BW's own direction enum is not yet + -- confirmed). The first blackout point is the floor below (zone 390's own + -- spot). BW's intro is not ported, so a new game opens in the room. + local room, below = out.zones and out.zones[391], out.zones and out.zones[390] + local boot = room and { + startMap = "Z391", startX = room.spawnX, startY = room.spawnZ, startFacing = "left", + lastHeal = below and { map = "Z390", x = below.spawnX, y = below.spawnZ } or nil, + screens = { splash = "Gen4Intro", title = "Gen4Title", newGame = false, options = "Gen4Options" }, + } or nil + self:write("field", { source = "ROM:" .. tostring(self.version), boot = boot, + playerSprites = { walk = hero }, playerForms = { order = { "boy" }, boy = { label = "BOY", walk = hero } } }) + end + local Tileset = require("src.import.Gen4Tileset") + self:write("map_tilesets", { [Tileset.PRIMARY_ID] = Tileset.primary() }) + self:write("tilesets", { [Tileset.ID] = Tileset.pair() }) + self:saveImage("tileset/standin", Tileset.atlas(), { key = true }) + -- the script pool (Gen5ScriptLink), once the scripts stage has the widths + local pool, linkReport = { source = "RomExtractorGen4", scripts = {}, maps = {}, bands = {} }, nil + local scriptBytes = self._scriptWidths and self.rom:read("/a/0/5/7") + if scriptBytes then + local Link = require("src.import.Gen5ScriptLink") + self._scriptBands = self._scriptBands or Link.findBands(self.rom) + pool, linkReport = Link.pool(Narc.parse(scriptBytes), + self._scriptWidths, out.zones, out.maps, self._scriptBands) + end + self:write("map_scripts", pool) + self.linkReport = linkReport + self.fieldReport = report + return out +end + +-- CONSTANTS, the part of the Gen 4 table that is not Sinnoh's: the types +-- (bank 199) and natures (bank 24), the growth curves -- /a/0/1/7, eight +-- members of 404 bytes, 101 u32 apiece, the shape of Gen 4's growth table, +-- and its first six in the same order (medium slow reaches 1,059,860) -- and +-- Unova's eight badges. The message window is Gen 4's rectangle until Black +-- and White's own is identified. +X.GROWTH = { "MEDIUM_FAST", "ERRATIC", "FLUCTUATING", "MEDIUM_SLOW", "FAST", "SLOW" } +X.BADGES = { "TRIO", "BASIC", "INSECT", "BOLT", "QUAKE", "JET", "FREEZE", "LEGEND" } +function X:extractConstants() + self:beginStage("constants") + local function order(n) local t = {} for i = 1, n do t[i] = i end return t end + local types, natures = {}, {} + for i = 0, 16 do types[i] = self:string(X.BANK.typeName, i) or D.TYPES[i] end + for i = 0, 24 do natures[i + 1] = self:string(X.BANK.nature, i) end + local curves + local bytes = self.rom:read("/a/0/1/7") + local growth = bytes and Narc.parse(bytes) + if growth then + curves = {} + for i, name in ipairs(X.GROWTH) do + local m = growth:get(i - 1) + if m and #m >= 404 then + local curve = {} + for level = 0, 100 do + local a, b, c, d = m:byte(level * 4 + 1, level * 4 + 4) + curve[level + 1] = a + b * 256 + c * 65536 + d * 16777216 + end + curves[name] = curve + end + end + end + local badges = {} + for i, id in ipairs(X.BADGES) do badges[i] = { id = "BADGE_" .. id } end + local constants = { + gen = 5, generation = 5, family = "bw", + experienceTables = curves, badges = badges, + types = types, typeCount = 17, natures = natures, + natureOrder = order(25), speciesOrder = order(X.SPECIES_COUNT), + gen4MessageWindow = { left = 2, top = 19, width = 27, height = 4, source = "Gen 4's rectangle" }, + gen4Screen = { width = 256, height = 192 }, + world = { blockPx = 16, stepFrames = 8, runStepFrames = 4 }, + source = ("ROM:%s (types bank %d, natures bank %d, curves /a/0/1/7)") + :format(tostring(self.version), X.BANK.typeName, X.BANK.nature), + } + -- the flags and vars a new game opens with (Gen5ScriptLink.newGame) + local scriptBytes = self.rom:read("/a/0/5/7") + local scriptArc = scriptBytes and Narc.parse(scriptBytes) + if scriptArc then + local Link = require("src.import.Gen5ScriptLink") + local derived = require("src.import.Gen5ScriptOps").derive(self.rom, scriptArc) + self._scriptBands = self._scriptBands or Link.findBands(self.rom) + constants.gen4NewGame = derived and Link.newGame(scriptArc, derived.widths, self._scriptBands) or nil + end + self:write("constants", constants) + return constants +end + +-- THE TYPE CHART: a 17 x 17 byte table, attacker by defender in Gen 5's type +-- order (Gen5Data.TYPES), each byte 0 / 2 / 4 / 8 for x0 / x0.5 / x1 / x2. +-- Found by its Normal row -- neutral but x0.5 against Rock and Steel and x0 +-- against Ghost -- with Fighting's x2 against Normal under it, and every one of +-- the 289 bytes one of the four values (overlay 93 in Black). Written in the +-- Gen 4 chart's shape: the non-neutral matchups, multipliers in tenths. +function X.findTypeChart(bytes) + local ok = { [0] = true, [2] = true, [4] = true, [8] = true } + for i = 1, #bytes - 289 do + if bytes:byte(i) == 4 and bytes:byte(i + 5) == 2 and bytes:byte(i + 7) == 0 + and bytes:byte(i + 8) == 2 and bytes:byte(i + 17) == 8 then + local good = true + for k = 0, 288 do if not ok[bytes:byte(i + k)] then good = false break end end + if good then return i - 1 end + end + end + return nil +end + +function X:extractTypeChart() + local found, overlay, at + for o = 0, 160 do + local okRead, b = pcall(function() return self.rom:overlay(o) end) + if okRead and b and #b > 289 then + local p = X.findTypeChart(b) + if p then found, overlay, at = b, o, p break end + end + end + if not found then return nil end + local TENTHS = { [0] = 0, [2] = 5, [4] = 10, [8] = 20 } + local out = { matchups = {}, types = {}, ids = {}, + source = ("ROM:%s overlay %d+0x%X, 17x17"):format(tostring(self.version), overlay, at) } + for i = 0, 16 do + local name = D.TYPES[i] + out.ids[name] = i + out.types[name] = { name = name } + end + for a = 0, 16 do + for d = 0, 16 do + local v = found:byte(at + a * 17 + d + 1) + if v ~= 4 then + out.matchups[#out.matchups + 1] = { attacker = D.TYPES[a], defender = D.TYPES[d], multiplier = TENTHS[v] } + end + end + end + self:write("type_chart", out) + self.typeChartReport = { overlay = overlay, at = at, matchups = #out.matchups } + return out +end + +function X:extractEncounters() + self:beginStage("encounters") + local bytes = self.rom:read("/a/1/2/6") + local out = bytes and require("src.import.Gen5Encounters").all(Narc.parse(bytes)) or {} + self:write("encounters", out) + return out +end + +-- THE FONT (/a/0/2/3 member 0, the 12 x 15 message font; Gen5Font), as the +-- `font` table Gen 4's writer makes: one sheet, the measured widths, and a +-- charmap from glyph code to UTF-8 -- the Gen 5 text banks are UTF-16, which +-- Gen5Text renders straight to UTF-8, so the code map IS the charmap. +function X:extractFont() + self:beginStage("fonts") + local bytes = self.rom:read("/a/0/2/3") + local arc = bytes and Narc.parse(bytes) + local F = require("src.import.Gen5Font") + local font = arc and F.parse(arc:get(0)) + if not font then return nil end + local sheet = F.sheet(font, { columns = 32 }) + local entry = sheet and self:saveImage("font/font_message_sheet", sheet, + { glyphs = font.numGlyphs, glyphWidth = font.pixelWidth, glyphHeight = font.pixelHeight, columns = 32 }) + local widths = {} + for i = 0, font.numGlyphs - 1 do widths[i + 1] = font.widths[i] end + local page = { image = entry and entry.path or nil, base = 1, glyphsPerRow = 32, + glyphWidth = font.pixelWidth, glyphHeight = font.pixelHeight, + advance = font.maxWidth, widths = widths, preTinted = true } + local def = { pages = { message = page }, charmap = F.charmap(font, 1), frame = "drawn", + source = ("ROM:%s /a/0/2/3[0] NFTR (%d glyphs, %dx%d)") + :format(tostring(self.version), font.numGlyphs, font.pixelWidth, font.pixelHeight) } + self:write("font", def) + return def +end + +-- SCRIPTS (Gen5ScriptOps): the command widths derived from the cartridge's own +-- handlers, and every script file's entry points. What the commands MEAN is +-- not identified yet, so nothing is lowered; this is the decode the lowering +-- will stand on. +function X:extractScripts() + self:beginStage("scripts") + local Ops = require("src.import.Gen5ScriptOps") + local Script = require("src.import.Gen4Script") + local bytes = self.rom:read("/a/0/5/7") + local arc = bytes and Narc.parse(bytes) + if not arc then return nil end + local derived = Ops.derive(self.rom, arc) + if not derived then return nil end + local files, blocks, clean = {}, 0, 0 + for m = 0, arc.count - 1 do + local d = arc:get(m) + local entries = Script.entries(d) + files[m] = entries + for _, at in ipairs(entries) do + blocks = blocks + 1 + local _, why = Ops.decode(d, at, derived.widths) + if why == "end" then clean = clean + 1 end + end + end + local out = { widths = derived.widths, offset = derived.offset, table = derived.table, + entries = files, report = { entries = blocks, clean = clean, + secondBestOffset = derived.report.secondBest } } + self:write("gen5_scripts", out) + self.scriptReport = out.report + self._scriptWidths = derived.widths + return out +end + +function X:run() + self:extractConstants() + self:extractScripts() + self:extractFont() + self:extractTypeChart() + self:extractEncounters() + self:extractText() + self:extractSpecies() + self:extractMoves() + self:extractItems() + self:extractTrainers() + self:extractField() + local skipped = {} + for _, name in ipairs(X.ALL_STAGES) do + local done = false + for _, n in ipairs(X.STAGES) do if n == name then done = true end end + if not done then skipped[#skipped + 1] = name end + end + self.notPorted = skipped + self:write("gen5_version", { version = self.version, family = "bw", stages = X.STAGES, notPorted = skipped, + scriptBands = self._scriptBands }) + return self.wrote +end + +return X diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 496566a39..d73567de9 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1587,6 +1587,8 @@ local REQUIRED_FILES_GEN4 = { local function requiredFiles(version) local gen = GameVersion.generation(version) if gen == 4 then return REQUIRED_FILES_GEN4 end + -- Black and White: their own list as the Gen 5 stages arrive (RomExtractorGen5) + if gen == 5 then return require("src.import.RomExtractorGen5").REQUIRED_FILES end if gen == 3 then return REQUIRED_FILES_GEN3 end if gen == 2 then return REQUIRED_FILES_GEN2 end return REQUIRED_FILES_GEN1 @@ -1686,6 +1688,14 @@ local PAL = { -- and a chip that has to be read twice has failed. chipPlatinumTop = { 166, 176, 216 }, -- #a6b0d8 Platinum chipPlatinumBot = { 74, 82, 134 }, -- #4a5286 + chipHeartGoldTop = { 236, 186, 72 }, -- #ecba48 HeartGold + chipHeartGoldBot = { 140, 82, 20 }, -- #8c5214 + chipSoulSilverTop = { 176, 196, 222 }, -- #b0c4de SoulSilver + chipSoulSilverBot = { 70, 92, 126 }, -- #465c7e + chipBlackTop = { 96, 100, 112 }, -- #606470 Black + chipBlackBot = { 24, 26, 32 }, -- #181a20 + chipWhiteTop = { 238, 240, 246 }, -- #eef0f6 White + chipWhiteBot = { 146, 150, 164 }, -- #9296a4 chipPrismTop = { 106, 240, 150 }, -- #6af096 Prism chipPrismBot = { 22, 138, 82 }, -- #168a52 -- Polished Crystal: amethyst, deliberately far from Crystal's cyan and @@ -2945,7 +2955,7 @@ function RomImporter.new(onComplete, opts) self.returning[version] = (not ready) and marker ~= nil and head ~= markerFor(version) self.romName[version] = "pokemon_" .. info.id - .. (info.generation == 4 and ".nds" + .. (info.generation >= 4 and ".nds" or info.generation == 3 and ".gba" or ((info.id == "yellow" or info.generation == 2) and ".gbc" or ".gb")) end @@ -3326,10 +3336,10 @@ function RomImporter:startData(data, displayName, sourcePath, verified) local CacheFs=require('src.import.CacheFs') local modules={[1]='src.import.RomExtractor',[2]='src.import.RomExtractorGen2', - [3]='src.import.RomExtractorGen3',[4]='src.import.RomExtractorGen4'} + [3]='src.import.RomExtractorGen3',[4]='src.import.RomExtractorGen4',[5]='src.import.RomExtractorGen5'} local task=require('src.import.RomImportTask').new({action='extract',version=version, generation=info.generation,module=modules[info.generation],manifest=manifest, - prefix=info.cachePrefix,root=CacheFs.root(),path=self.romPath,bytes=info.generation~=4 and self.romData or nil,clear=true,recovery=self.importRecovery}) + prefix=info.cachePrefix,root=CacheFs.root(),path=self.romPath,bytes=info.generation<4 and self.romData or nil,clear=true,recovery=self.importRecovery}) if task then self.romData=nil local complete=false @@ -3382,6 +3392,9 @@ function RomImporter:startData(data, displayName, sourcePath, verified) -- opens the cartridge itself instead of being handed its bytes. [4] = { module = "src.import.RomExtractorGen4", takesVersion = true, takesPath = true }, + -- ...and the fifth, Black and White: a DS cartridge like the fourth + [5] = { module = "src.import.RomExtractorGen5", takesVersion = true, + takesPath = true }, } local choice = EXTRACTORS[info.generation] if not choice then @@ -7615,6 +7628,19 @@ function RomImporter:_drawTabBar(x, y, w, h, chip) { id = "platinum", letter = "PL", top = PAL.chipPlatinumTop, bot = PAL.chipPlatinumBot, under = PAL.chipPlatinumTop, label = Strings("PLATINUM"), ink = PAL.chipInkSilver }, + -- Gen 4's other pair and Gen 5's, after Platinum in generation order + { id = "heartgold", letter = "HG", top = PAL.chipHeartGoldTop, + bot = PAL.chipHeartGoldBot, under = PAL.chipHeartGoldTop, + label = Strings("HEARTGOLD"), ink = PAL.chipInkGold }, + { id = "soulsilver", letter = "SS", top = PAL.chipSoulSilverTop, + bot = PAL.chipSoulSilverBot, under = PAL.chipSoulSilverTop, + label = Strings("SOULSILVER"), ink = PAL.chipInkSilver }, + { id = "black", letter = "B", top = PAL.chipBlackTop, + bot = PAL.chipBlackBot, under = PAL.chipBlackTop, + label = Strings("BLACK"), ink = PAL.white }, + { id = "white", letter = "W", top = PAL.chipWhiteTop, + bot = PAL.chipWhiteBot, under = PAL.chipWhiteTop, + label = Strings("WHITE"), ink = PAL.chipInkSilver }, -- Prism sits after Crystal: it is a Crystal romhack, so it belongs at the -- end of the Gen 2 run rather than beside the official carts. { id = "prism", letter = "P", top = PAL.chipPrismTop, bot = PAL.chipPrismBot, @@ -7907,6 +7933,10 @@ function RomImporter:_drawGamePanel(version, x, y, w, h, paged) elseif version == "firered" then accent = PAL.chipFireRedTop elseif version == "emerald" then accent = PAL.chipEmeraldTop elseif version == "platinum" then accent = PAL.chipPlatinumTop + elseif version == "heartgold" then accent = PAL.chipHeartGoldTop + elseif version == "soulsilver" then accent = PAL.chipSoulSilverTop + elseif version == "black" then accent = PAL.chipBlackTop + elseif version == "white" then accent = PAL.chipWhiteTop elseif version == "prism" then accent = PAL.chipPrismTop -- Without a branch here the panel silently falls back to PAL.blue, which -- reads as a rendering bug rather than a missing case. diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index 6b1071434..6070fd31e 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -387,6 +387,19 @@ end -- cannot tell one home from another. function LauncherMods.underRoot(root, path) if not (root and path) then return false end + -- A MOD THE GAME SHIPS WITH IS IN EVERY HOME. The bundled mods live in the + -- game archive (or the source folder), not in any save directory, so asked + -- this question they answered "no" the moment a player picked a custom + -- game-data folder -- and DRAMATIC SHAPES vanished from the launcher and + -- the game alike, reported as "not bundled in the build". + local fs = love and love.filesystem + if fs and fs.getRealDirectory and fs.getSource then + local okR, realDir = pcall(fs.getRealDirectory, path) + local okS, source = pcall(fs.getSource) + if okR and okS and realDir and source and realDir == source then + return true + end + end local real = LauncherMods.realPath(root, path) local handle = io.open(real .. CacheFs.SEP .. "manifest.json", "rb") if handle then handle:close() return true end diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 009656bdb..742fbbecc 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -816,10 +816,11 @@ function Loader:_api(mod) -- returns the same module table the engine holds, so a mod that mutates -- one is changing the engine, exactly as it always was. engineRequire = engineRequire, - -- 1, 2 or 3. The one field worth reaching for before anything else. + -- 1 to 5. The one field worth reaching for before anything else. generation = hostGen, -- the version id the launcher started: "red", "crystal", "emerald", - -- "prism", "polishedcrystal" + -- "platinum", "heartgold", "soulsilver", "black", "white", "prism", + -- "polishedcrystal" gameVersion = hostId, -- ...and the same three plus the trimmings, for a mod that would rather -- read one table than three fields. `name` is what the game calls diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index 00b72a719..ac5614634 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -112,7 +112,8 @@ end -- patches is what it patches, and the registry remains the truth once loaded. -- `generations`: [1, 2] -- the generations a mod is written for. -- --- Validated to the three that exist and sorted, so the set is comparable and +-- Validated to the generations that exist (ModGens.COUNT -- 1 to 5) and +-- sorted, so the set is comparable and -- an out-of-range entry is dropped rather than widening the mod's claim. An -- empty or malformed list reads as "unstated" (nil), NOT as "none": a mod that -- says nothing intelligible must keep working exactly as it did. @@ -122,7 +123,10 @@ local function parseGenerations(raw) local seen, out = {}, {} for _, entry in ipairs(list) do local n = tonumber(entry) - if n and n % 1 == 0 and n >= 1 and n <= 3 and not seen[n] then + -- WAS `n <= 3`, which silently dropped a mod's claim to Gen 4 (and would + -- have dropped Gen 5): a Platinum mod that said "generations": [4] read as + -- saying nothing at all + if n and n % 1 == 0 and n >= 1 and n <= require("src.mods.ModGens").COUNT and not seen[n] then seen[n] = true out[#out + 1] = n end diff --git a/src/mods/ModGens.lua b/src/mods/ModGens.lua index 388faaded..a033efd9c 100644 --- a/src/mods/ModGens.lua +++ b/src/mods/ModGens.lua @@ -28,8 +28,9 @@ local ModGens = {} -ModGens.GENERATIONS = { 1, 2, 3, 4 } -ModGens.COUNT = 4 +-- Five since Black and White: Gen 5 gets its own chip like every other. +ModGens.GENERATIONS = { 1, 2, 3, 4, 5 } +ModGens.COUNT = 5 -- Built from COUNT rather than written out, so that adding a generation is the -- two lines above and not a hunt for every literal triple. It used to be @@ -232,7 +233,7 @@ function ModGens.gensOf(value) return gens end -ModGens.LABELS = { "GEN 1", "GEN 2", "GEN 3", "GEN 4" } -ModGens.SHORT = { "G1", "G2", "G3", "G4" } +ModGens.LABELS = { "GEN 1", "GEN 2", "GEN 3", "GEN 4", "GEN 5" } +ModGens.SHORT = { "G1", "G2", "G3", "G4", "G5" } return ModGens diff --git a/src/render/Gen4Camera.lua b/src/render/Gen4Camera.lua index f12b5772a..a14e3e5c0 100644 --- a/src/render/Gen4Camera.lua +++ b/src/render/Gen4Camera.lua @@ -77,8 +77,68 @@ Gen4Camera.TYPES = { Gen4Camera.DEFAULT_TYPE = 0 -function Gen4Camera.forType(id) - return Gen4Camera.TYPES[tonumber(id) or -1] or Gen4Camera.TYPES[Gen4Camera.DEFAULT_TYPE] +-- HEARTGOLD AND SOULSILVER'S SEVENTEEN, which are NOT Platinum's. +-- +-- Same struct, same count -- FieldCamera_Create (pokeheartgold +-- asm/overlay_01_021EABA8.s) indexes a 0x24-byte table, ov01_02206478, and +-- asserts the type is below 0x11 -- but other numbers: HGSS's DEFAULT keeps +-- Platinum's distance and field of view and leans 48.7 degrees rather than +-- 59.1, with its far plane at 1200 rather than 900, and several types offset +-- the look-at point. The rows are the overlay's bytes as pret has them, and +-- they are decoded exactly as the asm reads them: +-- +-- 00 fx32 distance 04 u16 CameraAngle.x (stored negated) +-- 0C u16 projection (0 perspective, 1 orthographic) +-- 0E u16 perspective angle (the half fov) +-- 10 fx32 near 14 fx32 far 18 VecFx32 look-at offset +-- +-- Selected by `def.cameraFamily`, which the HGSS extractor writes on every +-- map; the controls, the tilt ladder and the renderer are Platinum's. +Gen4Camera.HGSS_ROWS = { + "c1ae290062dd0000000000000000c1050060090000004b00000000000000000000000000", + "5c46190083e3000000000000000081090060080000004b0000000000005002000010ffff", + "c1ae2900c2e30000000000000000c1050060090000004b00000000000000000000000000", + "c1ae290042f20000000000000000c1050060090000004b0000000000c5e90100373fffff", + "9bb8610082dc000000000000010081020060090000706c00000000000000000000000000", + "f6191d00a2dd00000000000000008108006009000080480000000000000000000080feff", + "c1ae2900e2df0000000000000000c1050060090000c05d00000000000000000000000000", + "c1ae2900e2e10000000000000000c1050060090000004b00000000005b7c0100e295feff", + "4c37200022d9000000000000000070070060090000403800000000000000000000000000", + "c1be290082d50000000000000000c1050060090000004b00000000000000000000000000", + "05c8130042df0000000000000000810c0060090000406a0000000000f1c500008ca3fdff", + "295c2100c2e3000000000000000041070060090000004b00000000000080ffff00000000", + "c1ae2900e2df0000000000000000c1050060090000406a0000000000000000000000feff", + "c1ae290042f20000000000000000c1050060090000406a0000000000c5e90100371ffeff", + "c1ae2900c2d40000000000000000c1050060090000004b00000000000000000000000000", + "9bb8610082dc000000000000010081020060090000706c0000000000000000000020fdff", + "c1ae290002d60000000000000000c1050060090000403800000000000000000000000000", +} +local function hgssType(hex, i) + local b = {} + for k = 1, #hex, 2 do b[#b + 1] = tonumber(hex:sub(k, k + 1), 16) end + local function u16(o) return b[o + 1] + b[o + 2] * 256 end + local function s32(o) + local v = u16(o) + u16(o + 2) * 65536 + return v >= 2147483648 and v - 4294967296 or v + end + local fx = function(o) return s32(o) / 4096 end + local angle = u16(4) + return { + id = ("hgss_%d"):format(i), family = "hgss", + distance = fx(0), + pitch = (65536 - angle) % 65536 * 360 / 65536, + projection = u16(0x0C) == 1 and "orthographic" or "perspective", + halfFov = u16(0x0E) * 360 / 65536, + near = fx(0x10), far = fx(0x14), + offset = { x = fx(0x18), y = fx(0x1C), z = fx(0x20) }, + } +end +Gen4Camera.HGSS_TYPES = {} +for i, hex in ipairs(Gen4Camera.HGSS_ROWS) do Gen4Camera.HGSS_TYPES[i - 1] = hgssType(hex, i - 1) end + +function Gen4Camera.forType(id, family) + local types = family == "hgss" and Gen4Camera.HGSS_TYPES or Gen4Camera.TYPES + return types[tonumber(id) or -1] or types[Gen4Camera.DEFAULT_TYPE] end -- Half the screen height in WORLD UNITS at the target plane: tan(fovY) * d. @@ -503,7 +563,10 @@ end -- The camera a map is drawn with: its header's type, with the OPTIONS row's -- pitch substituted when the player has chosen one. function Gen4Camera.forMap(def) - local config = Gen4Camera.forType(def and def.cameraType) + -- Black / White carry their camera record on the def (Gen5Field: the + -- zone's index into /a/0/6/0, decoded at import) + local config = def and def.gen5Camera + or Gen4Camera.forType(def and def.cameraType, def and def.cameraFamily) local chosen = Gen4Camera.externalTilt or Gen4Camera.TILTS[Gen4Camera.chosen] if chosen == "cartridge" then return config end local copy = {} diff --git a/src/render/Gen4Ground.lua b/src/render/Gen4Ground.lua index afe877f3b..f0641bbf0 100644 --- a/src/render/Gen4Ground.lua +++ b/src/render/Gen4Ground.lua @@ -297,7 +297,12 @@ function Gen4Ground.forMap(map, data) -- ...AND THE HIDDEN PATHS, which change both: Spring Path's chunks are drawn -- as filler at altitude 2 until it is unlocked, and Seabreak Path's appear -- once Oak's Letter has been used. See Gen4HiddenPaths. - do + -- PLATINUM'S ONLY. The paths are cells of Platinum's matrix 0, Sinnoh; on + -- HeartGold and SoulSilver matrix 0 is Johto and on Black and White it is + -- Unova, and stamping Sinnoh's chunk ids into those put a Platinum chunk in + -- the middle of Route 1 (drawn untextured, since its set is Sinnoh's). + local otherCartridge = def.gen5 or def.cameraFamily == "hgss" + if not otherCartridge then local okG, Game = pcall(require, "src.core.Game") local save = okG and type(Game) == "table" and Game.save or nil local Gen4HiddenPaths = require("src.world.Gen4HiddenPaths") @@ -306,6 +311,11 @@ function Gen4Ground.forMap(map, data) grid = self.grid end self.set = set + -- BLACK / WHITE'S RAIL MAPS (Gen5Field's gen5NoGrid): their NG chunks are + -- models that reach two or three chunks past their own cell, and the rails + -- run well outside the matrix's few cells, so the camera's cell can be off + -- the grid entirely. The whole matrix (at most 2 x 3) is always drawn. + self.drawAllChunks = def and def.gen5NoGrid or nil self.chunkPx = (terrain.chunkUnits or 512) * (terrain.pixelsPerUnit or 1) self.half = (terrain.chunkUnits or 512) / 2 -- THE MAP'S OWN CAMERA, by the `cameraType` byte in its header. A cache @@ -344,6 +354,18 @@ function Gen4Ground.forMap(map, data) -- extracted draws the floors and nothing on them, which is what it did -- before and is better than refusing to draw the ground. self.buildingSet = ((data.gen4_models or {}).sets or {}).buildings + -- HEARTGOLD AND SOULSILVER keep a room's buildings in their own archive + -- (Gen4HgssArea): an indoor map's prop ids index `interiorBuildings`. + -- Platinum's cache has no such set, so its maps are untouched. + local interior = ((data.gen4_models or {}).sets or {}).interiorBuildings + if interior and record and record.outdoors == false then self.buildingSet = interior end + -- BLACK AND WHITE: a building id names a model in the map's own pack + -- (Gen5Field: record.buildingKind / buildingPack) + local bwSets = (data.gen4_models or {}).sets or {} + if record and record.buildingKind and (bwSets.bwOutdoor or bwSets.bwIndoor) then + local kind = record.buildingKind == "outdoor" and bwSets.bwOutdoor or bwSets.bwIndoor + self.buildingSet = kind and kind.packs and kind.packs[record.buildingPack] or nil + end self.buildings = {} -- THE OBJECTS THAT ARE MODELS RATHER THAN BILLBOARDS -- every sign and every -- mailbox in Sinnoh. Absent on a cache imported before `fldeff.narc` was @@ -1048,6 +1070,8 @@ function Gen4Ground:building(index, archive) -- untextured rather than wearing a borrowed picture: a wrong picture on -- a building looks deliberate, and a flat one does not. image = s.image, + -- out of a fogged map's fog (Gen4ModelPack): Morty's candle lights + noFog = s.noFog, } end if #shapes == 0 then @@ -1061,7 +1085,7 @@ function Gen4Ground:building(index, archive) -- to one ground, so a house in Twinleaf and -- the same house in Sandgem are two meshes -- under two lights, which is the point. - shade = self.shade, + shade = self.shade, fog = self.fog, -- INDOORS ONLY, and only where the cartridge -- left a gap. Requested as *"implement -- shadows for indoor objects"*: Platinum's @@ -1170,14 +1194,51 @@ function Gen4Ground:objectsFor(land, record) if edits and edits[tostring(land)] then own=edits[tostring(land)] end local mine = self:signpostsFor(land) local heal = self.healingProps and self.healingProps[land] - if (not mine or #mine == 0) and not heal then return own end + local placed = self:runtimePropsFor(land) + if (not mine or #mine == 0) and not heal and not placed then return own end local all = {} for _, o in ipairs(own) do all[#all + 1] = o end for _, o in ipairs(mine or {}) do all[#all + 1] = o end for _, o in ipairs(heal or {}) do all[#all + 1] = o end + for _, o in ipairs(placed or {}) do all[#all + 1] = o end return all end +-- PROPS A SCRIPT STANDS ON THE MAP, the cartridge's MapPropManager_LoadOne: +-- `def.gen4RuntimeProps` is a list of { model, x, y, z } in this map's own +-- world units (16 to a tile, from its top-left corner), written by the +-- command that places them -- HGSS's PlaceStarterBallsInElmsLab is the first. +-- On the def rather than on this ground because a map's init script runs +-- before its renderer exists; read here and re-split by chunk whenever the +-- list changes. +function Gen4Ground:runtimePropsFor(land) + local list = self.def and self.def.gen4RuntimeProps + if type(list) ~= "table" or #list == 0 then return nil end + if self.runtimePropsList ~= list or self.runtimePropsCount ~= #list then + self.runtimePropsList, self.runtimePropsCount = list, #list + local grid, terrain = self.grid, self.terrain + local unitsPerChunk = (terrain and terrain.chunkUnits) or 512 + local half = unitsPerChunk / 2 + local byLand = {} + for _, p in ipairs(list) do + local mx, mz = (p.x or 0) + (self.offsetX or 0), (p.z or 0) + (self.offsetY or 0) + local cx, cy = math.floor(mx / unitsPerChunk), math.floor(mz / unitsPerChunk) + if grid and mx >= 0 and mz >= 0 and cx < grid.width and cy < grid.height then + local at = grid.land[cy * grid.width + cx + 1] + if at then + byLand[at] = byLand[at] or {} + table.insert(byLand[at], { model = p.model, x = mx % unitsPerChunk - half, + y = p.y or 0, z = mz % unitsPerChunk - half, rotY = p.rotY, ground = p.ground, + scaleX = p.scale or 1, scaleY = p.scale or 1, scaleZ = p.scale or 1 }) + end + end + end + self.runtimeProps = byLand + if self.dropBakes then self:dropBakes() end + end + return self.runtimeProps and self.runtimeProps[land] or nil +end + -- The healing balls are map props in Platinum, placed relative to the -- console, so they participate in the same depth/camera pass as its ROM art. function Gen4Ground:setHealingBalls(count, visible) @@ -1226,6 +1287,19 @@ local function placement(object) local sx = (object.scaleX and object.scaleX ~= 0) and object.scaleX or 1 local sy = (object.scaleY and object.scaleY ~= 0) and object.scaleY or 1 local sz = (object.scaleZ and object.scaleZ ~= 0) and object.scaleZ or 1 + -- BLACK AND WHITE TURN THEIR BUILDINGS (Gen5Field: a quarter-turn per + -- placement, `rotY` in degrees about the vertical). Platinum's records + -- carry no rotation and have no `rotY`, so their matrix is unchanged. + local yaw = object.rotY and object.rotY ~= 0 and math.rad(object.rotY) or nil + if yaw then + local c, s = math.cos(yaw), math.sin(yaw) + return { + c * sx, 0, s * sz, object.x or 0, + 0, sy, 0, object.y or 0, + -s * sx, 0, c * sz, object.z or 0, + 0, 0, 0, 1, + } + end return { sx, 0, 0, object.x or 0, 0, sy, 0, object.y or 0, @@ -1340,7 +1414,7 @@ function Gen4Ground:modelFor(land) -- ...AND THE LIGHT. Without this the mesh is built -- from white vertices and no camera can make it read -- as anything but flat. - shade = self.shade, + shade = self.shade, fog = self.fog, -- HOW BIG A TILE IS, for the standing grass. Read -- off the cache rather than assumed: `tileUnits` is -- what the importer measured. @@ -1704,7 +1778,21 @@ function Gen4Ground:livePass(camX, camY, vw, vh, yCut) -- is the only place that identity is written down now. local _, pz = self:focus(camX, camY, vw, vh) local playerZ = pz + (self.offsetY or 0) - + -- ...AND THE ROW THE PLAYER STANDS IN, WHATEVER THE HEIGHT. The camera + -- follows the player's ELEVATED position, so `top` rises with the floor + -- they stand on while the chunk rows above are chosen as though the ground + -- were flat. Falkner's gym lifts the player 464 units -- the view's top + -- edge went to -450, the range became row -1 alone and the whole gym drew + -- black. The player's own row and its neighbours are always in view. + -- The camera target's feet as the overworld followed them (`followCamera` + -- sets `cameraFeetZ`); `focus` is worked back from the RAISED view and + -- lands rows above the floor on exactly the maps this is for. + local feetZ = self.cameraFeetZ and (self.cameraFeetZ + (self.offsetY or 0)) or playerZ + local pcy = math.floor(feetZ / px) + if pcy - 1 < y0 then y0 = pcy - 1 end + if pcy + 1 > y1 then y1 = pcy + 1 end + + if self.drawAllChunks then x0, y0, x1, y1 = 0, 0, grid.width - 1, grid.height - 1 end for cy = y0, y1 do for cx = x0, x1 do if cx >= 0 and cy >= 0 and cx < grid.width and cy < grid.height then @@ -1777,7 +1865,13 @@ function Gen4Ground:livePass(camX, camY, vw, vh, yCut) -- ones, so the object's own lift and scale come back off before -- it is sent -- the same arithmetic `bakeCanopy` does. local cut, skip = nil, false - if yCut then + -- A FLOOR TO STAND ON is never painted over the people on it + -- (a runtime prop marked `ground`: Clair's platforms, whose + -- origin is their middle row, so anyone on the rows north of + -- it was covered by the whole platform) + if yCut and object.ground then + skip = true + elseif yCut then local sy = (object.scaleY and object.scaleY ~= 0) and object.scaleY or 1 -- THE PROP'S OWN HEIGHT, NOT ITS HEIGHT ABOVE SEA LEVEL. -- @@ -2183,8 +2277,10 @@ function Gen4Ground:drawFree(vw, vh) -- so no other caller inherits it. Gen4Model.billboardYaw = view.yaw or 0 local drawn = 0 - for cy = camCy - FREE_RADIUS, camCy + FREE_RADIUS do - for cx = camCx - FREE_RADIUS, camCx + FREE_RADIUS do + local fy0, fy1, fx0, fx1 = camCy - FREE_RADIUS, camCy + FREE_RADIUS, camCx - FREE_RADIUS, camCx + FREE_RADIUS + if self.drawAllChunks then fy0, fy1, fx0, fx1 = 0, grid.height - 1, 0, grid.width - 1 end + for cy = fy0, fy1 do + for cx = fx0, fx1 do if cx >= 0 and cy >= 0 and cx < grid.width and cy < grid.height then local land = grid.land[cy * grid.width + cx + 1] local model = self:liveModel(land) @@ -3184,6 +3280,7 @@ function Gen4Ground:drawCanopy(camX, camY, vw, vh) local r, gr, b, a = g.getColor() g.setColor(1, 1, 1, 1) local drawn = 0 + if self.drawAllChunks then x0, y0, x1, y1 = 0, 0, grid.width - 1, grid.height - 1 end for cy = y0, y1 do for cx = x0, x1 do if cx >= 0 and cy >= 0 and cx < grid.width and cy < grid.height then @@ -3403,6 +3500,11 @@ function Gen4Ground:draw(camX, camY, vw, vh) -- apart at noon, so a second of lag is invisible and sixty calls a second -- are waste. Members 1 and 2 -- 483 of the 593 maps -- are constant across -- all fifteen bands and will never take this branch at all. + -- A MAP FOGGED BY ITS SCRIPT (`def.gen4Fog`, an {r, g, b} 0..1 -- HGSS's + -- Morty's gym): baked into the vertex colours like the light, so a change + -- rebuilds the meshes + local fog = self.def and self.def.gen4Fog + if fog ~= self.fog then self.fog = fog; self:dropLitModels() end self.lightCheck = (self.lightCheck or 0) + 1 if self.lightCheck >= 60 then self.lightCheck = 0 @@ -3464,8 +3566,16 @@ function Gen4Ground:draw(camX, camY, vw, vh) -- 50.6% of ground pixels moved at the CARTRIDGE rung, 65.5% at rung 30. -- -- Read at the view's centre, which is where the overworld keeps the player. + -- ...WHERE THE OVERWORLD SAYS THE FEET ARE, when it has (`followCamera`): + -- the view's centre is worked back from the raised sprite, and on + -- Falkner's walkway it lands off the floor at height 0 -- which anchored + -- the spread 496 units low and drew the top floor at three times its size. if self.spread then - self.spread[2] = self:groundY(self:viewCentre(camX, camY, vw, vh)) + if self.cameraFeetX and self.cameraFeetZ then + self.spread[2] = self:groundY(self.cameraFeetX, self.cameraFeetZ) + else + self.spread[2] = self:groundY(self:viewCentre(camX, camY, vw, vh)) + end end local g = love.graphics @@ -3490,6 +3600,10 @@ function Gen4Ground:draw(camX, camY, vw, vh) -- and this leaves it alone. if not self.cameraPlaced then local ex, ey = self:viewCentre(camX, camY, vw, vh) + -- ...or the target's real feet, when the overworld said where they are + -- (`followCamera`): the view centre is worked back from the raised + -- sprite and is rows off on a tall map + if self.cameraFeetX and self.cameraFeetZ then ex, ey = self.cameraFeetX, self.cameraFeetZ end -- NO FACING. This used to pass `self.view3d.yaw`, which is read BEFORE -- `follow` syncs itself to the kept look and would therefore pin the -- camera to this view's own stale angle -- the very divergence the sync @@ -3510,6 +3624,7 @@ function Gen4Ground:draw(camX, camY, vw, vh) -- Lifting the eye rather than pulling it in: pulling in pushes the camera -- through the player instead, which is the same hole from the other side. local v = self.view3d + if v and v.mode == "third" then self:clearCameraLine(v) end if v and v.mode ~= "first" then local floorY = self:groundY(v.x - (self.offsetX or 0), v.z - (self.offsetY or 0)) @@ -3596,6 +3711,7 @@ function Gen4Ground:draw(camX, camY, vw, vh) -- world running out; with both zero the geometry is elsewhere. local drawn, missing, empty = 0, 0, 0 local paintTop, paintBottom = math.huge, -math.huge + if self.drawAllChunks then x0, y0, x1, y1 = 0, 0, grid.width - 1, grid.height - 1 end for cy = y0, y1 do for cx = x0, x1 do if cx >= 0 and cy >= 0 and cx < grid.width and cy < grid.height then @@ -3911,6 +4027,56 @@ function Gen4Ground:permBlockFor(land) return block end +-- THE THIRD-PERSON CAMERA STOPS IN FRONT OF A WALL. +-- +-- Requested: *"fix the third person camera clipping too"*. The eye sits on a +-- sphere behind the player (Gen4View:follow) and nothing stood between the +-- two: back the player up to a Pokemon Center and the eye ended INSIDE its +-- roof, the frame one red wall; beside a tree, all leaves. +-- +-- The line from the pivot (chest to eye height, `view.pivotX/Y/Z`) to the eye +-- is marched a few units at a time. A sample is in the way when its tile is +-- BLOCKED (bit 15 of the permission word -- buildings, trees, walls, the +-- same cells the player cannot walk into), is not water (a lake behind the +-- player is something to look over, not a wall), and the line there is lower +-- than `CAMERA_OBSTACLE` above that tile's own ground. The eye then comes in +-- along the same line to just short of it -- so the player stays centred and +-- the pitch does not change -- and never nearer than `CAMERA_NEAREST`. +-- Tiles are a proxy for the geometry: a low fence reads as a wall and draws +-- the camera in a little, which is the safe side to be wrong on. +Gen4Ground.CAMERA_OBSTACLE = 32 +Gen4Ground.CAMERA_NEAREST = 12 +Gen4Ground.CAMERA_MARGIN = 6 +function Gen4Ground:clearCameraLine(v) + local px, py, pz = v.pivotX, v.pivotY, v.pivotZ + if not (px and py and pz and v.x and v.y and v.z) then return end + local dx, dy, dz = v.x - px, v.y - py, v.z - pz + local len = math.sqrt(dx * dx + dy * dy + dz * dz) + if len <= Gen4Ground.CAMERA_NEAREST then return end + local ox, oz = self.offsetX or 0, self.offsetY or 0 + local homeX, homeY = math.floor((px - ox) / 16), math.floor((pz - oz) / 16) + local Behaviors = require("src.import.Gen4Behaviors") + local step = 4 + local hit + for d = step, len, step do + local f = d / len + local sx, sy, sz = px + dx * f, py + dy * f, pz + dz * f + local tx, ty = math.floor((sx - ox) / 16), math.floor((sz - oz) / 16) + if tx ~= homeX or ty ~= homeY then + local word = self:permissionWordAt(tx, ty) + if word and word >= 0x8000 and not Behaviors.isSurfable(word % 256) then + local top = self:groundY(tx * 16 + 8, ty * 16 + 8) + Gen4Ground.CAMERA_OBSTACLE + if sy < top then hit = d break end + end + end + end + if not hit then return end + local keep = math.max(Gen4Ground.CAMERA_NEAREST, hit - Gen4Ground.CAMERA_MARGIN) + if keep >= len then return end + local t = keep / len + v.x, v.y, v.z = px + dx * t, py + dy * t, pz + dz * t +end + -- `tileX`/`tileY` are MAP-LOCAL tiles, the same space `heightsAt` takes, so a -- caller that has a cell has no second coordinate system to learn. function Gen4Ground:permissionWordAt(tileX, tileY) diff --git a/src/render/Gen4Model.lua b/src/render/Gen4Model.lua index e940f6768..8035ec690 100644 --- a/src/render/Gen4Model.lua +++ b/src/render/Gen4Model.lua @@ -1,2541 +1,2555 @@ --- Copyright (c) 2026 Cedric. All rights reserved. --- Source-available under the Gen2Recomped License (see LICENSE.md): you may --- read, build and privately modify this file; you may not redistribute it or --- use it commercially. Cartridge-derived data is excluded and is not the --- copyright holder's to license. - --- Drawing a Gen 4 model, which is the first real 3D this engine does itself. --- --- The voxel mod has its own 3D and the tilt ground quad is one mesh with one --- shader, but the engine core has never had a general "here is a mesh, draw --- it" path -- so Platinum's 590 buildings, Giratina on the title, the field --- effects and Professor Rowan's briefcase all had geometry sitting in the --- cache with nothing able to put it on screen. --- --- THIS IS DELIBERATELY SELF-CONTAINED. It does not register a pipeline, does --- not touch the world pass, and does not ask the Renderer for anything: a --- screen creates one of these, draws it into its own canvas, and throws it --- away. The reason is that the first thing to use it is the starter select -- --- a menu with three Poke Balls in a briefcase -- which has no world behind it --- and no business being part of the world's pipeline. When the map meshes --- arrive they will want the pipeline; a menu does not, and building for the --- harder case first would mean neither worked. --- --- WHAT DEPTH COSTS. A 3D scene needs a depth buffer, which means a canvas --- created with one and `setDepthMode` around the draw. Both are restored --- afterwards, unconditionally, because this runs inside somebody else's draw --- and leaving either set turns the next 2D blit into a puzzle. - -local Assets = require("src.render.Assets") -local Logger = require("src.core.Logger") --- The pose walk lives with the reader that produced the bytecode. Requiring --- an importer from a renderer is not pretty; having the same walk written --- twice and drifting apart would be worse, and this is the walk that decides --- where every shape stands. -local Gen4Nsbmd = require("src.import.Gen4Nsbmd") - -local Gen4Model = {} -Gen4Model.__index = Gen4Model - --- Matches Gen4ModelPack: a vertex is fourteen bytes and a coordinate is fx16. --- 16, not 14: the last three bytes are the normal, as three signed bytes. --- --- Platinum's display lists issue NORMAL and no COLOR -- the DS lights in --- hardware -- so a vertex whose normal was dropped in packing keeps the --- decoder's white default, and every polygon draws with no directional term. --- That is what made the whole of Sinnoh read flat under a correct camera. -local VERTEX_BYTES = 16 --- What it was before the normal was added. Kept so a cache written by an --- older import is READ correctly rather than read confidently and wrongly. -local LEGACY_VERTEX_BYTES = 14 -local FX16 = 4096 -local UV_UNITS = 16 - --- The vertex shader takes a model-view-projection matrix and nothing else. --- --- `TransformProjectionMatrix` is LOVE's own 2D transform and is deliberately --- ignored: the whole point here is to put clip-space coordinates out directly, --- and mixing the two would apply the 2D camera to a 3D scene. --- ...AND A TEXTURE TRANSFORM, which is what a Gen 4 texture animation is. --- --- `bm_anime`'s BTA0 animations scroll, scale and rotate a material's texture --- coordinates -- that is how Platinum's water moves and its fountains run. --- THE UNITS ARE NORMALISED, measured rather than assumed: every scroll in that --- archive runs its translate from 0.0 to exactly -1.0 over its own frame count --- (the waterfall to -2.0, which is two cycles in the same time) with scale held --- at 1.0. A translate that lands on whole units is one full wrap of the --- texture; texel units would have run to 16 or 64 and do not. --- --- This port's UVs are already divided by the texture's size when the mesh is --- built, so the transform applies directly with nothing to convert. --- --- Two vectors rather than a mat3 because a 2x2 and an offset is all a texture --- SRT is, and because every shape sends them on every draw -- including the --- overwhelming majority that are identity. Sending them ALWAYS is deliberate: --- a uniform that is declared and not sent reads as zero, and a zero texture --- matrix collapses every coordinate to one texel, which is a model drawn in a --- single flat colour. --- `yCut` IS WHAT MAKES WALK-BEHIND POSSIBLE. --- --- Every fragment carries the model-space height it came from, and the pixel --- stage drops the ones at or below the cut. Drawing a building twice -- once --- whole, under the sprites, and once with everything below head height cut --- away, over them -- is what lets a character pass BEHIND a house: the roof --- and upper walls are painted after the sprite, the ground floor is not, and --- somebody standing in front of the door is still drawn in front of it. --- --- The default is a number no geometry reaches, so a caller that does not ask --- for a cut gets the whole model. It is SENT ON EVERY DRAW rather than left --- unset, because an unset uniform reads as zero and zero would quietly cut --- every model in the game off at the waist. -local SHADER = [[ -varying float vModelY; -varying float vModelZ; -#ifdef VERTEX -uniform mat4 mvp; -// x = how much a unit of HEIGHT spreads; y = THE HEIGHT IT SPREADS ABOUT. -// -// The datum is not 0 and assuming it was is a measured fault. The factor is -// exactly 1 at y = heightSpread.y, so that is the plane the picture pivots -// around -- and the plane it has to be is THE ONE THE SPRITES STAND ON, since -// they are placed by the flat projection and get no spread at all. Twinleaf's -// ground is at y = 16, so anchoring at 0 scaled the whole ground plane about -// the screen centre while the characters on it stayed put: 50.6% of ground -// pixels moved at the CARTRIDGE rung and 65.5% at rung 30. Reported from play -// as standing "a few blocks back" from a building one tile away, and as the -// player looking "smaller than i should be" -- the world was being scaled up -// around them and they were not. -uniform vec2 heightSpread; -// THE CARD'S OWN AXIS, and the angle every card turns about it. -// -// `BillboardPivot.xy` is the card's centre in x and z; `.z` is 1 on a card. -// `billboardYaw` is the camera's heading, and it is ZERO on every pass that is -// not a free camera -- which is what leaves the field and tilt views -// arithmetically identical, since a rotation by zero is the identity. -// -// ABOUT Y ONLY. Requested: trees *"should always face the players camera"* -// but *"also dont change when looking up or down"*. A full look-at billboard -// tips the card back as the camera rises and the trees appear to lie down; a -// turn about the vertical keeps the cartridge's own 35-degree lean exactly as -// authored and only spins it to face you. -attribute vec3 BillboardPivot; -uniform float billboardYaw; - -vec4 turnToCamera(vec4 v) -{ - if (BillboardPivot.z < 0.5) { return v; } - vec2 d = v.xz - BillboardPivot.xy; - float sa = sin(billboardYaw); - float ca = cos(billboardYaw); - // THE SIGN IS THE ENGINE'S, not a textbook's. `Gen4View:follow` puts the - // eye at `x - sin(yaw) * r, z + cos(yaw) * r` -- the x term is NEGATIVE -- - // so a rotation written the usual way turns the cards the wrong way and - // doubles the error instead of cancelling it. Measured with a camera - // built by `Gen4View` itself: the wrong sign collapsed a card from 6264 - // painted pixels to 188 at 60 degrees. - v.xz = BillboardPivot.xy + vec2(d.x * ca - d.y * sa, d.x * sa + d.y * ca); - return v; -} -vec4 position(mat4 transform_projection, vec4 vertex_position) -{ - vec4 turned = turnToCamera(vertex_position); - vModelY = turned.y; - vModelZ = turned.z; - vec4 p = mvp * turned; - // THE ONE PERSPECTIVE TERM, and the reason it is here and not in `mvp`. - // - // A tree's top is nearer the eye than its base by `h * cos(pitch)`, so the - // cartridge's camera spreads it outward from the screen centre by - // `D / (D - h*cos(pitch))`. That factor multiplies the vertex's SCREEN - // OFFSET by a function of its HEIGHT -- a product of two coordinates, - // which no 4x4 matrix can express. It has to be a per-vertex divide, so - // it lives here. - // - // Screen centre is NDC (0,0) because the matrix is built per frame around - // the camera, so scaling p.xy about the origin IS spreading from the - // centre of the view. At h = 0 the factor is exactly 1, which is what - // keeps the GROUND PLANE pixel-identical -- and therefore every sprite, - // every collision pick and every screen-row-to-tile-row answer the - // overworld already computes. - float s = 1.0 - (turned.y - heightSpread.y) * heightSpread.x; - if (s < 0.25) { s = 0.25; } - p.xy /= s; - return p; -} -#endif -#ifdef PIXEL -uniform vec4 uvRotScale; -uniform vec2 uvTranslate; -uniform float yCut; -// (lo, hi] in model z: a band of the cut pass that is left unpainted -- the -// ground just BEHIND the player, see Gen4Ground:livePass. Empty by default. -uniform vec2 zKeep; -vec4 effect(vec4 colour, Image tex, vec2 uv, vec2 screen) -{ - if (vModelY <= yCut) { discard; } - if (vModelZ > zKeep.x && vModelZ <= zKeep.y) { discard; } - vec2 t = vec2(uvRotScale.x * uv.x + uvRotScale.y * uv.y, - uvRotScale.z * uv.x + uvRotScale.w * uv.y) + uvTranslate; - vec4 texel = Texel(tex, t); - // A Gen 4 texture keeps colour 0 transparent, and a transparent texel must - // not write depth -- otherwise the hole punched through a leaf or a strap - // occludes whatever is behind it. Discarding is what makes that correct - // rather than merely usually correct. - if (texel.a < 0.5) { discard; } - return texel * colour; -} -#endif -]] - --- THE SAME SHADER WITHOUT THE HEIGHT CUT, kept as a fallback. --- --- The cut needs a `varying`, and a varying is the one part of this shader that --- a driver can reasonably refuse -- GLES wants a precision qualifier, and some --- older GL profiles are fussy about declaring one outside the stage blocks. --- If it will not compile, losing walk-behind is a much smaller loss than --- losing every model in the game, which is what `ensureShader` returning nil --- costs: `draw` bails on a nil shader and the whole world goes black. -local SHADER_NO_CUT = [[ -#ifdef VERTEX -uniform mat4 mvp; -// x = how much a unit of HEIGHT spreads; y = THE HEIGHT IT SPREADS ABOUT. -// -// The datum is not 0 and assuming it was is a measured fault. The factor is -// exactly 1 at y = heightSpread.y, so that is the plane the picture pivots -// around -- and the plane it has to be is THE ONE THE SPRITES STAND ON, since -// they are placed by the flat projection and get no spread at all. Twinleaf's -// ground is at y = 16, so anchoring at 0 scaled the whole ground plane about -// the screen centre while the characters on it stayed put: 50.6% of ground -// pixels moved at the CARTRIDGE rung and 65.5% at rung 30. Reported from play -// as standing "a few blocks back" from a building one tile away, and as the -// player looking "smaller than i should be" -- the world was being scaled up -// around them and they were not. -uniform vec2 heightSpread; -// THE CARD'S OWN AXIS, and the angle every card turns about it. -// -// `BillboardPivot.xy` is the card's centre in x and z; `.z` is 1 on a card. -// `billboardYaw` is the camera's heading, and it is ZERO on every pass that is -// not a free camera -- which is what leaves the field and tilt views -// arithmetically identical, since a rotation by zero is the identity. -// -// ABOUT Y ONLY. Requested: trees *"should always face the players camera"* -// but *"also dont change when looking up or down"*. A full look-at billboard -// tips the card back as the camera rises and the trees appear to lie down; a -// turn about the vertical keeps the cartridge's own 35-degree lean exactly as -// authored and only spins it to face you. -attribute vec3 BillboardPivot; -uniform float billboardYaw; - -vec4 turnToCamera(vec4 v) -{ - if (BillboardPivot.z < 0.5) { return v; } - vec2 d = v.xz - BillboardPivot.xy; - float sa = sin(billboardYaw); - float ca = cos(billboardYaw); - // THE SIGN IS THE ENGINE'S, not a textbook's. `Gen4View:follow` puts the - // eye at `x - sin(yaw) * r, z + cos(yaw) * r` -- the x term is NEGATIVE -- - // so a rotation written the usual way turns the cards the wrong way and - // doubles the error instead of cancelling it. Measured with a camera - // built by `Gen4View` itself: the wrong sign collapsed a card from 6264 - // painted pixels to 188 at 60 degrees. - v.xz = BillboardPivot.xy + vec2(d.x * ca - d.y * sa, d.x * sa + d.y * ca); - return v; -} -vec4 position(mat4 transform_projection, vec4 vertex_position) -{ - vec4 turned = turnToCamera(vertex_position); - vec4 p = mvp * turned; - float s = 1.0 - (turned.y - heightSpread.y) * heightSpread.x; - if (s < 0.25) { s = 0.25; } - p.xy /= s; - return p; -} -#endif -#ifdef PIXEL -uniform vec4 uvRotScale; -uniform vec2 uvTranslate; -vec4 effect(vec4 colour, Image tex, vec2 uv, vec2 screen) -{ - vec2 t = vec2(uvRotScale.x * uv.x + uvRotScale.y * uv.y, - uvRotScale.z * uv.x + uvRotScale.w * uv.y) + uvTranslate; - vec4 texel = Texel(tex, t); - if (texel.a < 0.5) { discard; } - return texel * colour; -} -#endif -]] - -local shader -local shaderCuts = false - -local function ensureShader() - if shader ~= nil then return shader or nil end - local ok, made = pcall(love.graphics.newShader, SHADER) - if ok and made then - shader, shaderCuts = made, true - return shader - end - Logger.warn("gen4 model: the mesh shader with the height cut would not " - .. "compile (%s) -- falling back to the plain one, so models " - .. "still draw but nothing will occlude a sprite", - tostring(made)) - local plainOk, plain = pcall(love.graphics.newShader, SHADER_NO_CUT) - if not plainOk or not plain then - Logger.error("gen4 model: the mesh shader would not compile (%s); " - .. "no 3D model will draw", tostring(plain)) - shader = false - return nil - end - shader, shaderCuts = plain, false - return shader -end - --- Whether this device got the shader that can cut a model off at a height. --- The canopy pass asks, because baking one that cannot cut would paint whole --- buildings over the sprites instead of only their roofs. -function Gen4Model.cutsHeight() - ensureShader() - return shaderCuts -end - --- --------------------------------------------------------------------------- --- Matrices --- --------------------------------------------------------------------------- - --- Row-major 4x4s, as flat sixteen-element tables, because that is the layout --- Shader:send takes and converting once here beats converting at every draw. -local function identity() - return { 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 } -end - -local function multiply(a, b) - local out = {} - for row = 0, 3 do - for col = 0, 3 do - local sum = 0 - for k = 0, 3 do - sum = sum + a[row * 4 + k + 1] * b[k * 4 + col + 1] - end - out[row * 4 + col + 1] = sum - end - end - return out -end - -function Gen4Model.perspective(fovY, aspect, near, far) - local f = 1 / math.tan(fovY / 2) - return { - f / aspect, 0, 0, 0, - 0, f, 0, 0, - 0, 0, (far + near) / (near - far), (2 * far * near) / (near - far), - 0, 0, -1, 0, - } -end - --- THE OTHER PROJECTION THE CARTRIDGE USES. --- --- Three hundred of Platinum's 593 map headers are CAMERA_TYPE_INTERIOR_- --- ORTHOGRAPHIC -- every ordinary room, the player's bedroom included -- and --- `Camera_ComputeProjectionMatrix` really does build an orthographic box for --- them. A perspective matrix at the same numbers is not an approximation of --- that, it is a different picture: an orthographic camera has no parallax at --- all, which is exactly what the cartridge wants indoors. --- --- `halfHeight` is in WORLD UNITS at the target plane, which is the form the --- cartridge states it in (`top = tan(fovY) * distance`). -function Gen4Model.orthographic(halfHeight, aspect, near, far) - local h = (tonumber(halfHeight) or 1) - if h < 1e-6 then h = 1e-6 end - local w = h * (tonumber(aspect) or 1) - near, far = tonumber(near) or 1, tonumber(far) or 4096 - if far - near < 1e-6 then far = near + 1 end - return { - 1 / w, 0, 0, 0, - 0, 1 / h, 0, 0, - 0, 0, -2 / (far - near), -(far + near) / (far - near), - 0, 0, 0, 1, - } -end - --- A camera that looks at a point from a distance, around it and above it. --- Spelled out rather than composed from a `lookAt` because the only thing that --- ever moves it is a turntable, and two angles read better than an eye vector. -function Gen4Model.orbit(target, distance, yaw, pitch) - local cy, sy = math.cos(yaw), math.sin(yaw) - local cp, sp = math.cos(pitch), math.sin(pitch) - local rotateY = { - cy, 0, -sy, 0, - 0, 1, 0, 0, - sy, 0, cy, 0, - 0, 0, 0, 1, - } - local rotateX = { - 1, 0, 0, 0, - 0, cp, sp, 0, - 0, -sp, cp, 0, - 0, 0, 0, 1, - } - local translate = identity() - translate[4] = -(target and target[1] or 0) - translate[8] = -(target and target[2] or 0) - translate[12] = -(target and target[3] or 0) - local back = identity() - back[12] = -(distance or 1) - return multiply(back, multiply(rotateX, multiply(rotateY, translate))) -end - --- Exported beside the two camera helpers, because a caller that builds its own --- view and projection has to combine them and re-deriving a 4x4 multiply at --- every call site is how two of them end up disagreeing. -Gen4Model.multiply = multiply - --- A camera placed where the cartridge puts it, looking where it looks. --- --- `orbit` above is the right shape for a turntable and the wrong one for a --- scripted shot: Platinum's title camera is two POINTS that interpolate -- --- eye (0, 192, 600) to (-64, 192, 484) over sixty frames, target fixed at --- (0, 100, -18) -- and turning a pair of points into a distance and two --- angles to hand to `orbit` would throw away the straight line between them. -function Gen4Model.lookAt(eye, target, up) - up = up or { 0, 1, 0 } - local fx, fy, fz = target[1] - eye[1], target[2] - eye[2], target[3] - eye[3] - local fl = math.sqrt(fx * fx + fy * fy + fz * fz) - if fl < 1e-6 then fl = 1 end - fx, fy, fz = fx / fl, fy / fl, fz / fl - local sx = fy * up[3] - fz * up[2] - local sy = fz * up[1] - fx * up[3] - local sz = fx * up[2] - fy * up[1] - local sl = math.sqrt(sx * sx + sy * sy + sz * sz) - -- Looking straight down the up axis: any side vector will do, and picking - -- one is better than dividing by zero. - if sl < 1e-6 then sx, sy, sz, sl = 1, 0, 0, 1 end - sx, sy, sz = sx / sl, sy / sl, sz / sl - local ux = sy * fz - sz * fy - local uy = sz * fx - sx * fz - local uz = sx * fy - sy * fx - return { - sx, sy, sz, -(sx * eye[1] + sy * eye[2] + sz * eye[3]), - ux, uy, uz, -(ux * eye[1] + uy * eye[2] + uz * eye[3]), - -fx, -fy, -fz, (fx * eye[1] + fy * eye[2] + fz * eye[3]), - 0, 0, 0, 1, - } -end - --- --------------------------------------------------------------------------- --- Loading --- --------------------------------------------------------------------------- - -local FORMAT = { - { "VertexPosition", "float", 3 }, - { "VertexTexCoord", "float", 2 }, - -- FLOAT, NOT BYTE, AND THAT IS A BUG FIX RATHER THAN A PREFERENCE. - -- - -- LOVE hands a "byte" vertex attribute to the shader UNNORMALISED -- the - -- fragment stage sees 0..255, not 0..1 -- so `texel * colour` came out at - -- 255x the texel and clamped to white for EVERY value. Measured at the - -- smallest scale that can show it: a 1x1 white texture on a three-vertex - -- mesh renders pixel-identical at vertex colour 255 and at 128 under "byte", - -- and 1.000 against 0.502 under "float", on both the custom shader and - -- LOVE's default one. - -- - -- So this format could never have shown a vertex colour at all. It did not - -- matter while every vertex in the cache was white; it is the second half of - -- why lighting the world changed nothing, and it would have quietly eaten - -- the first half. - { "VertexColor", "float", 4 }, - -- WHERE THIS VERTEX'S CARD TURNS, and whether it turns at all. - -- - -- x and y are the card's centre in the chunk's own x and z; z is 1 on a - -- camera-facing card and 0 on everything else. It is PER VERTEX rather than - -- per shape because a terrain shape is every tree on the chunk at once -- - -- `tree2_01` reaches 240 separate quads in one shape -- and they each have - -- to turn about themselves, not about the shape's middle. - -- - -- Local to this file, so Gen 1/2/3 are untouched: they have their own model - -- modules and never see this format. - { "BillboardPivot", "float", 3 }, -} - --- A vertex that is not part of a card. Shared rather than built per vertex, --- because most of Sinnoh is not a tree. -local NO_PIVOT = { 0, 0, 0 } - --- WHICH FACES THE CARTRIDGE AUTHORED TO FACE THE CAMERA. --- --- Platinum builds a tree as a flat quad leaning back toward its fixed camera, --- and that lean is a single exact normal: (0, 0.819, 0.575). Measured over --- the whole cartridge it appears on `tree01`, `tree2_01`, `tree04_2`, --- `conttree_b/t`, `conttree2_b/t`, `tree3_02`, `bf_tree03` and nothing else -- --- 60,273 triangles across 45 materials. Walls are (1,0,0) or (0,0,1), the --- ground is (0,1,0), and `searock` and `imped` lean at a DIFFERENT 45 degrees, --- so none of them are caught. --- --- Two simpler rules were tried and thrown away. "Taller than it is flat" --- catches every dungeon wall and step. "Made only of 4-vertex quads" is --- better -- a tree shape really is 56 to 240 loose quads while a wall is 6- --- and 8-vertex boxes -- but the FLAT quads are the sea, the lakes and the --- grass floor, and standing those on end would take the world apart. --- --- The normal is computed from the geometry rather than read from the vertex, --- because a cache imported before normals were carried is 14 bytes a vertex --- and has none. The lighting pass measured the same normal independently off --- a 16-byte cache, which is a second source for it. -local TREE_NY, TREE_NZ, TREE_TOL = 0.819, 0.575, 0.04 - --- billboardPivots(shape, positions, count) -> per-vertex pivot, card count --- --- Each card gets its OWN centre, found by walking the shape's triangles as a --- graph: a card is one connected component, and every one of them is exactly --- four vertices. --- HOW MANY TIMES A CARD REPEATS ITS TEXTURE ACROSS ITS WIDTH, above which it --- is a CONTINUOUS STRIP and not a tree. --- --- Reported from play, twice: *"the border trees ... rotating as a large group --- of trees instead of individually above their trunks"*, and again after the --- pivot was moved to the base -- *"many groups of trees canopys are still --- pivoting as if theyre rows rather than from the trunk of every tree"*. --- --- The second report is not a pivot fault. Measured: 100% of card components --- are exactly one quad, so nothing is being merged. What IS happening is that --- Platinum draws a forest border as ONE CARD -- `conttree` is *continuous* --- tree -- 256 world units wide and 20 tall, repeating a 64-pixel texture four --- times. All 2,956 `conttree_t` cards and all 3,738 `conttree2_t` cards are --- wider than 48 units; the widest are 256. A quad cannot bend, so there is no --- pivot that turns those four trees individually: the geometry for them does --- not exist. --- --- SUBDIVIDING THEM WAS TRIED AND ABANDONED. Splitting a strip at its texture --- tiles needs the repeat count to be a whole number, and across the cartridge --- **23,171 of 30,147 cards have a non-integer u-span over texture width** -- --- the UVs address sub-rectangles, not whole tiles. There is no derivable place --- to cut, and cutting at a guessed one would break a strip into panels that --- swing apart at the seams. --- --- So a strip is left exactly as the cartridge drew it, which is what it was --- authored for, and only cards that really are one tree turn to face you. --- 1.5 rather than 2 so a card that repeats "about twice" is treated as the --- strip it is. --- 1.25, NOT 1.5, AND THE DIFFERENCE IS THE WHOLE OF REPORT #188. --- --- Reported from play a third time: *"some of the trees are pivoting together --- for example 3 trees are pivoting from one point"*. --- --- Measured over all 666 chunks of the cartridge, u-span divided by texture --- width lands on clean values -- and 2,396 components sit at EXACTLY 1.50: --- --- 0.66 x6328 0.53 x6279 0.34 x6272 <- single trees --- 4.00 x3534 (excluded) 2.50 x1187 (excluded) 2.00 x1159 (excluded) --- 1.50 x2396 <- ON the old threshold, and the test is `>`, so KEPT --- 1.00 x2250 <- a single tree, correctly kept --- --- A strict `>` against 1.5 lets every one of those 2,396 through, and at a --- 64-wide texture that component is 96 world units across: THREE 32-unit --- trees turning about one pivot, which is the report word for word. --- --- 1.25 rather than 1.4 or 1.49 because it is the MIDDLE OF A MEASURED GAP: --- 21,758 components are at or under 1.00 repeats, 2,396 are at 1.50, and --- only EIGHTEEN of 30,147 lie strictly between the two. A threshold in an --- empty gap cannot be knocked over by float wobble in the s16 divide, which --- is exactly what setting it on 1.5 and testing with `>=` would risk. -local STRIP_REPEATS = 1.25 - --- ...AND A `conttree` IS NEVER ONE TREE, WHICH THE CARTRIDGE SAYS IN THE NAME. --- --- `conttree` is CONTINUOUS tree. The u-span test is a proxy -- it reads the --- texture's repeat, not the quad's size -- and it misses the strips whose UVs --- are stretched rather than tiled. The name does not miss them, and the --- separation is total: --- --- conttree* components n=10,217 min width 64.0 median 128 max 256 --- AT OR UNDER 40 UNITS: ZERO --- every other component n=19,930 median 33 98.3% at or under 40 --- --- The narrowest `conttree` in the game is 64 units, which is already two --- trees; an ordinary tree card is 33. There is no overlap to argue about. --- --- Effect, measured: billboarded cards wider than 48 units fall from 4,493 --- (18.6% of all cards) to 51 (0.3%), and the widest from 96.0 to 85.3. The --- name rule alone does most of it -- 18.6% to 0.4% -- and the threshold trims --- the rest. -local function isContinuousStrip(shape) - local name = shape and shape.texture - return type(name) == "string" and name:find("conttree", 1, true) ~= nil -end - -local function billboardPivots(shape, positions, count, textureWidth) - local parent = {} - for i = 1, count do parent[i] = i end - local function find(a) - while parent[a] ~= a do parent[a] = parent[parent[a]]; a = parent[a] end - return a - end - local function union(a, b) - a, b = find(a), find(b) - if a ~= b then parent[a] = b end - end - - local card = {} - local indices = shape.indices or "" - for t = 0, (shape.triangleCount or 0) - 1 do - local at = t * 6 - local a1, a2, b1, b2, c1, c2 = indices:byte(at + 1, at + 6) - if not c2 then break end - local a = a1 + a2 * 256 + 1 - local b = b1 + b2 * 256 + 1 - local cc = c1 + c2 * 256 + 1 - local pa, pb, pc = positions[a], positions[b], positions[cc] - if pa and pb and pc then - union(a, b) - union(b, cc) - local ux, uy, uz = pb[1] - pa[1], pb[2] - pa[2], pb[3] - pa[3] - local vx, vy, vz = pc[1] - pa[1], pc[2] - pa[2], pc[3] - pa[3] - local nx = uy * vz - uz * vy - local ny = uz * vx - ux * vz - local nz = ux * vy - uy * vx - local len = math.sqrt(nx * nx + ny * ny + nz * nz) - if len > 1e-6 then - nx, ny, nz = nx / len, ny / len, nz / len - -- The winding decides the sign and both windings occur, so the test is - -- made on the upward-pointing form of the normal. - if ny < 0 then nx, ny, nz = -nx, -ny, -nz end - if math.abs(nx) < TREE_TOL - and math.abs(ny - TREE_NY) < TREE_TOL - and math.abs(math.abs(nz) - TREE_NZ) < TREE_TOL then - card[a], card[b], card[cc] = true, true, true - end - end - end - end - - -- A CARD TURNS ABOUT WHERE IT STANDS, not about its middle. - -- - -- Reported from play, with a screenshot: *"the border trees to the town seem - -- to be rotating billboards weird as a large group of trees instead of - -- individually above their trunks"*. - -- - -- The first version pivoted on the component's CENTROID, and a leaning card's - -- centroid is not above its base -- the cartridge tilts them 35 degrees back, - -- so the middle of the quad hangs behind the trunk. Measured on Twinleaf's - -- own border: every `conttree_b` card's centroid is 13.9 units behind its - -- base and every `conttree_t` card's is 9.0 to 12.3. Turning each about that - -- point swings it through an arc it never should have left, which reads as - -- the whole tree line sweeping sideways -- and because the trunk's offset - -- (13.9) and the canopy's (12.3) DIFFER, the two halves of one tree come - -- apart as they turn. - -- - -- So the pivot is the middle of the card's LOWEST edge: the line where it - -- meets the ground, which is the trunk. A trunk and the canopy above it - -- share that axis even though they are separate components, so they stay - -- together. - -- A CONTINUOUS STRIP IS NOT A TREE -- see `STRIP_REPEATS` above. - -- - -- Measured per component rather than per shape, because `conttree_b` mixes - -- single cards with 256-wide strips in the same shape. - local tw = tonumber(textureWidth) - if tw and tw > 0 then - local ulo, uhi = {}, {} - for i = 1, count do - if card[i] then - local r = find(i) - local u = positions[i][4] - if u then - if ulo[r] == nil or u < ulo[r] then ulo[r] = u end - if uhi[r] == nil or u > uhi[r] then uhi[r] = u end - end - end - end - local strip = {} - for r, lo in pairs(ulo) do - if (uhi[r] - lo) > STRIP_REPEATS * tw then strip[r] = true end - end - if next(strip) then - for i = 1, count do - if card[i] and strip[find(i)] then card[i] = nil end - end - end - end - - -- OUTSIDE the `tw` block on purpose: the name is readable whether or not the - -- texture loaded, and a cache with no textures should still not billboard a - -- forest border as one card. - if isContinuousStrip(shape) then - for i = 1, count do card[i] = nil end - end - - local lowest = {} - for i = 1, count do - if card[i] then - local r = find(i) - local y = positions[i][2] - if lowest[r] == nil or y < lowest[r] then lowest[r] = y end - end - end - -- Half a unit: a card's base edge is flat, and the packed coordinates - -- quantise to about a sixtieth of a unit, so this catches the base and - -- nothing above it. - local BASE_BAND = 0.5 - local sumX, sumZ, n = {}, {}, {} - for i = 1, count do - if card[i] then - local r = find(i) - if positions[i][2] - lowest[r] <= BASE_BAND then - sumX[r] = (sumX[r] or 0) + positions[i][1] - sumZ[r] = (sumZ[r] or 0) + positions[i][3] - n[r] = (n[r] or 0) + 1 - end - end - end - -- A card lying exactly flat has no "lowest edge" to speak of and every - -- vertex lands in the band, which gives the centroid -- the right answer - -- for something with no height to lean. - local cards = 0 - for _ in pairs(n) do cards = cards + 1 end - - local out = {} - for i = 1, count do - if card[i] then - local r = find(i) - out[i] = { sumX[r] / n[r], sumZ[r] / n[r], 1 } - else - out[i] = NO_PIVOT - end - end - return out, cards -end - -local function s16(data, at) - local a, b = data:byte(at + 1, at + 2) - if not b then return 0 end - local value = a + b * 256 - if value >= 32768 then value = value - 65536 end - return value -end - --- One axis of a normal, back out of a signed byte. Nil past the end of the --- string, which is how a shorter vertex from an older cache announces itself --- rather than silently reading into its neighbour. -local function s8(data, at) - local byte = data:byte(at + 1) - if not byte then return nil end - if byte >= 128 then byte = byte - 256 end - return byte / 127 -end - --- new(record) -> model, or nil when the record carries nothing drawable. --- --- `record` is one entry of the cache's `gen4_models`: a name, a position scale --- and a list of shapes, each with its packed vertices, its packed indices and --- the texture its material wears. --- HOW FAR A GENERATED SHADOW SITS ABOVE THE FLOOR, in world units. --- --- Small enough to read as contact and large enough to beat the depth buffer's --- resolution at this camera's distances; a shadow written at exactly the floor --- height fights the floor and stipples. -local SHADOW_LIFT = 0.25 - --- The cartridge's own shadow translucency, 9 of 31, stated here because --- `SHADOW_ALPHA` is declared far below this and Lua scope runs downward. -local SHADOW_ALPHA_FRACTION = 9 / 31 - --- HOW FAR FROM THE CARTRIDGE'S OWN DRAWING SCALE A WALL COURSE MAY SIT. --- One octave either side of one texel per world unit; the measurements behind --- it are in `addBackWall`'s caller, where the bands are chosen. -local WALL_SCALE_LO, WALL_SCALE_HI = 0.5, 2.0 --- ...AND HOW LITTLE OF THE TEXTURE A FACE MAY SHOW AND STILL BE A COURSE. --- A rect a couple of texels across is a colour, not a picture, however --- plausible its vertical scale: `c1_s02`'s worst offender spreads TWO texels --- of u over 65.81 units. There is a clean gap in the measurements -- the --- flat-colour faces span 0.9 to 2.0 texels and the real courses 10 to 64 -- --- so this sits in the gap rather than on a slope. -local WALL_MIN_TEXELS = 4 - --- --------------------------------------------------------------------------- --- THE GRASS A WILD POKEMON LIVES IN, STOOD UP --- --------------------------------------------------------------------------- --- --- Requested: *"make the grass billboards that stand up and always face the --- camera"*, and then, when the first attempt stood up the wrong layer, --- *"looks like your making the wrong grass a billboard its supposed to be the --- grass wild pokemon are in"*. --- --- WHICH LAYER, and why a name is the right way to ask. Sinnoh's ground is --- drawn in layers, each its own polygon with its own material, and the --- cartridge separates the lawn from the encounter grass exactly that way: --- `ngrass` against `nectgr`. The renderer cannot ask the other question -- --- "is this tile TALL_GRASS" -- because the behaviour byte is a movement-layer --- fact that the terrain cache does not carry; it holds geometry and height and --- nothing else. So the material is the question that can be asked here, and --- it is the cartridge's own answer rather than a guess about one. --- --- MEASURED, ON THE ART, why the lawn must not be in this list. Every ground --- tile in the set is 100% opaque -- there is no cut-out anywhere -- so a card --- made from one is a solid rectangle, and what decides whether that reads as --- grass is how much of the tile is decoration rather than flat field colour. --- `ngrass` is 57.4% one flat green over four colours; `nectgr` is 21.1% flat --- over nine, the rest leaf. Stood up, the first is a green slab that blanks --- out the path behind it and the second is a clump of leaves. --- --- The list is names because only names are available, and it is one table so --- that importing another region's texture set is a one-line change. Only the --- materials that have actually been LOOKED at are in it. --- --- THE OTHER GRASS-ISH NAMES, now that they ARE extracted -- the note that used --- to sit here said they were not, and that stopped being true some imports ago. --- Census over all 666 land chunks, then each texture opened and viewed: --- --- `ngrass` (224 shapes) -- OUT. The lawn: 4 colours, 86.7% one flat green, --- border 100%. Stood up it is a slab that blanks the path behind it. --- `bf_ngrass` (9) -- OUT, and it is the same thing under another area's --- prefix: 8 colours, 66.4% flat, border 90.6%, and it looks like a lawn. --- `l_grass_u` / `_m` / `_d` (16 between them) -- OUT, and this one is worth --- the sentence: stacked u-m-d they form ONE CONTINUOUS COLUMN of grass --- blades three tiles tall, with the ground band at the foot of `_d`. That --- is grass the cartridge ALREADY DRAWS STANDING, on a vertical face. --- Standing it again would double it, and there is nothing to cut out -- --- the blades run edge to edge, which is why their border agreement is --- 12-23% against `nectgr`'s 85.9%. --- `s_grass` (20) -- IN. A dark spiky clump on red soil: 10 colours, 41.4% --- flat, border 67.2%. Structurally the same picture as `nectgr` -- a plant --- with a uniform field colour around it -- which is exactly what the --- cut-out below needs and what makes a card read as a clump. --- --- `nectgr` (97) -- IN, the original: 8 colours, 32.8% flat, border 85.9%. --- --- The `grow_*` singles and `gym04_daigrass02` are one shape each and have not --- been looked at; one shape is not worth a card until someone has. -local GRASS_MATERIALS = { nectgr = true, s_grass = true } - --- THE TILE'S BACKGROUND, CUT OUT. --- --- A ground tile is opaque all the way across -- the whole set is, there is no --- alpha anywhere in it -- so a card made from one is a rectangle, and stood up --- the plant reads as a clump inside a visible pale box. --- --- What that box is, is the GROUND: a tile has to meet the ground seamlessly on --- all four sides, so the colour running round its border is the field it sits --- in. Measured on `nectgr`, 63.3% of the border is (82,255,148) and that is --- also the tile's commonest colour overall at 21.1% -- the border and the whole --- agree, which is what says it is a background and not a pattern. On a --- VERTICAL card that colour is behind the plant rather than under it, so it is --- made transparent and the model shader's own alpha cut removes it. --- --- The flat quad keeps the untouched texture. Only the standing card wears --- this one, and it is derived from the cartridge's own pixels rather than --- drawn. -local grassCutouts = {} -local function grassCutout(path) - if not path then return nil end - local hit = grassCutouts[path] - if hit ~= nil then return hit or nil end - local okData, data = pcall(function() - return love.image.newImageData(Assets.resolve(path)) - end) - if not (okData and data) then grassCutouts[path] = false return nil end - local w, h = data:getDimensions() - if w < 3 or h < 3 then grassCutouts[path] = false return nil end - local counts, best, bestN = {}, nil, 0 - for y = 0, h - 1 do - for x = 0, w - 1 do - if x == 0 or y == 0 or x == w - 1 or y == h - 1 then - local r, g, b = data:getPixel(x, y) - local key = ("%d,%d,%d"):format(r * 255, g * 255, b * 255) - local n = (counts[key] or 0) + 1 - counts[key] = n - if n > bestN then bestN, best = n, key end - end - end - end - -- A border with no colour of its own is not a background. - if not best or bestN < (2 * (w + h) - 4) * 0.5 then - grassCutouts[path] = false - return nil - end - local br, bg, bb = best:match("^(%d+),(%d+),(%d+)$") - br, bg, bb = tonumber(br), tonumber(bg), tonumber(bb) - data:mapPixel(function(_, _, r, g, b, a) - if math.floor(r * 255 + 0.5) == br and math.floor(g * 255 + 0.5) == bg - and math.floor(b * 255 + 0.5) == bb then - return r, g, b, 0 - end - return r, g, b, a - end) - local okImg, img = pcall(love.graphics.newImage, data) - if not (okImg and img) then grassCutouts[path] = false return nil end - img:setFilter("nearest", "nearest") - img:setWrap("repeat", "repeat") - grassCutouts[path] = img - return img -end --- One standing card per TILE, not per triangle: a chunk's grass is merged into --- quads that span two tiles as often as one, so the triangles are grouped back --- onto the tile grid the cartridge measured (`tileUnits`, 16). --- --- The card is as tall as it is wide, because the texture is square and drawing --- it at any other aspect stretches art that was painted at 16x16. It stands --- on the LOWEST corner of its tile so it plants in sloping ground rather than --- floating over it, and it wears the tile's own UV rectangle, so a chunk whose --- grass sits in a corner of the atlas gets that corner and not the whole sheet. -local function addGrassCards(self, shape, vertices, positions, tileUnits) - local image = grassCutout(shape.image) - if not image then return false end - local indices = shape.indices or "" - local half = tileUnits * 0.5 - local tiles, order = {}, {} - - -- ONE CARD PER TILE THE SHAPE ACTUALLY COVERS, and the previous version could - -- not do that however it was keyed. - -- - -- It grouped TRIANGLES by the tile their centre fell in and then took that - -- group's min/max UV as the card's rectangle. Measured over all 666 chunks, - -- **2,812 of nectgr's 3,440 triangles (81.7%) span more than one tile**, and - -- the commonest case is exactly 2x2 (924 of them, then 2x1 and 1x2 at 288 and - -- 284, and 4x4 at 182). A quad covering 2x2 tiles has a UV range two texture - -- widths across in each axis, so the card wore the texture FOUR TIMES in one - -- square -- reported as *"some are rendered with 2x2 grass textures in a - -- square for 1 tile"*. The same group's min y is the low corner of the whole - -- patch rather than of one tile, so on sloping ground the card started below - -- the ground: *"some are but are too tall instead of starting at the ground"*. - -- One fault, two symptoms. - -- - -- Keying cannot fix it, because no key splits a triangle that covers several - -- tiles. So the walk is the other way round: for every tile a triangle - -- touches, ask the TRIANGLE what the ground and the texture do at that tile's - -- centre. A tile is claimed by the first triangle that actually contains its - -- centre, so a tile is never built twice and never averaged across a seam. - for t = 0, (shape.triangleCount or 0) - 1 do - local at = t * 6 - local a1, a2, b1, b2, c1, c2 = indices:byte(at + 1, at + 6) - if not c2 then break end - local ia = a1 + a2 * 256 + 1 - local ib = b1 + b2 * 256 + 1 - local ic = c1 + c2 * 256 + 1 - local pa, pb, pc = positions[ia], positions[ib], positions[ic] - local va, vb, vc = vertices[ia], vertices[ib], vertices[ic] - if pa and pb and pc and va and vb and vc then - -- THE PLANE OF THE TRIANGLE IN THE GROUND AXES. A grass quad is flat and - -- its mapping is linear, so y, u and v are each an affine function of - -- (x, z) and one barycentric solve serves all three. - local det = (pb[3] - pc[3]) * (pa[1] - pc[1]) - + (pc[1] - pb[1]) * (pa[3] - pc[3]) - if math.abs(det) > 1e-6 then - local xlo = math.min(pa[1], pb[1], pc[1]) - local xhi = math.max(pa[1], pb[1], pc[1]) - local zlo = math.min(pa[3], pb[3], pc[3]) - local zhi = math.max(pa[3], pb[3], pc[3]) - -- The nudge keeps a quad whose edge sits exactly on a tile boundary - -- from claiming the tile beyond it -- the corner-vs-centre problem the - -- old comment describes, arriving from the other side. - local tx0 = math.floor(xlo / tileUnits + 1e-4) - local tx1 = math.floor((xhi - 1e-4) / tileUnits) - local tz0 = math.floor(zlo / tileUnits + 1e-4) - local tz1 = math.floor((zhi - 1e-4) / tileUnits) - for tz = tz0, tz1 do - for tx = tx0, tx1 do - local key = ("%d,%d"):format(tx, tz) - if not tiles[key] then - local cx = (tx + 0.5) * tileUnits - local cz = (tz + 0.5) * tileUnits - local w1 = ((pb[3] - pc[3]) * (cx - pc[1]) - + (pc[1] - pb[1]) * (cz - pc[3])) / det - local w2 = ((pc[3] - pa[3]) * (cx - pc[1]) - + (pa[1] - pc[1]) * (cz - pc[3])) / det - local w3 = 1 - w1 - w2 - -- INSIDE THIS TRIANGLE, with a hair of slack so a centre landing - -- exactly on the shared edge of two triangles is not dropped by - -- both of them. - if w1 >= -1e-4 and w2 >= -1e-4 and w3 >= -1e-4 then - local function lerp(fa, fb, fc) return w1 * fa + w2 * fb + w3 * fc end - -- HOW FAST THE TEXTURE RUNS OVER THE GROUND, solved on this - -- triangle rather than assumed to be one tile per repeat: a - -- chunk whose grass sits in a corner of the atlas has its own - -- rate, and that is what decides the card's rectangle. - local iu = ((pb[3] - pc[3]) * (va[4] - vc[4]) - + (pc[3] - pa[3]) * (vb[4] - vc[4])) / det - local iv = ((pa[1] - pc[1]) * (vb[5] - vc[5]) - + (pc[1] - pb[1]) * (va[5] - vc[5])) / det - local u = lerp(va[4], vb[4], vc[4]) - local v = lerp(va[5], vb[5], vc[5]) - local tile = { - cx = cx, cz = cz, - -- THE GROUND AT THIS TILE'S OWN CENTRE, which is the whole of - -- the "too tall" fix: a group minimum over a 4x4 patch of - -- slope is metres below the tile the card stands on. - y = lerp(pa[2], pb[2], pc[2]), - -- EXACTLY ONE TILE OF TEXTURE, in each axis, whichever way - -- the mapping runs. The old rectangle was the whole merged - -- quad's and repeated once per tile it spanned. - uLo = u - iu * half, uHi = u + iu * half, - vLo = v - iv * half, vHi = v + iv * half, - } - tiles[key] = tile - order[#order + 1] = tile - end - end - end - end - end - end - end - - local verts, map = {}, {} - local lo = { math.huge, math.huge, math.huge } - local hi = { -math.huge, -math.huge, -math.huge } - for _, tile in ipairs(order) do - local cx, cz = tile.cx, tile.cz - local y0, y1 = tile.y, tile.y + tileUnits - local base = #verts - -- The pivot is the card's FOOT, so it turns about where it is planted -- - -- the same rule the trees needed, and for the same reason. - verts[#verts + 1] = - { cx - half, y0, cz, tile.uLo, tile.vHi, 1, 1, 1, 1, cx, cz, 1 } - verts[#verts + 1] = - { cx + half, y0, cz, tile.uHi, tile.vHi, 1, 1, 1, 1, cx, cz, 1 } - verts[#verts + 1] = - { cx + half, y1, cz, tile.uHi, tile.vLo, 1, 1, 1, 1, cx, cz, 1 } - verts[#verts + 1] = - { cx - half, y1, cz, tile.uLo, tile.vLo, 1, 1, 1, 1, cx, cz, 1 } - map[#map + 1] = base + 1; map[#map + 1] = base + 2; map[#map + 1] = base + 3 - map[#map + 1] = base + 1; map[#map + 1] = base + 3; map[#map + 1] = base + 4 - if cx - half < lo[1] then lo[1] = cx - half end - if cx + half > hi[1] then hi[1] = cx + half end - if y0 < lo[2] then lo[2] = y0 end - if y1 > hi[2] then hi[2] = y1 end - if cz < lo[3] then lo[3] = cz end - if cz > hi[3] then hi[3] = cz end - end - if #verts == 0 then return false end - - local okMesh, mesh = - pcall(love.graphics.newMesh, FORMAT, verts, "triangles", "static") - if not (okMesh and mesh) then return false end - pcall(mesh.setVertexMap, mesh, map) - mesh:setTexture(image) - self.shapes[#self.shapes + 1] = { - mesh = mesh, name = (shape.name or "grass") .. "Cards", index = nil, - material = shape.texture, image = image, - lo = lo, hi = hi, - } - return true -end --- --------------------------------------------------------------------------- --- SPLITTING A WALL COURSE INTO ITS SIDING AND ITS GLASS --- --------------------------------------------------------------------------- --- --- Reported from play: *"the houses have repeating window textures maybe just --- do one window and fill the rest with the wood texture"*. --- --- Correct, and the cause is that not every band is a course. `t1_s01`'s wall --- rect is 36 x 30 texels of siding-window-siding, and the cartridge puts it on --- ONE quad per side wall -- a panel with a window in it, never repeated. --- `t1_h01`'s rect really is a course and really does tile, twice per side wall --- and mirrored. Tiling both gives one right wall and one wall of windows. --- --- WHAT SEPARATES THEM IS PURE WHITE. Read off the two textures: `t1_h01`'s --- band runs from 0.542 to 0.821 luminance and never touches white, while --- `t1_s01`'s glass is exactly 1.000 against siding at 0.756 to 0.871. Glass --- is the only thing in a Platinum house wall drawn at the palette's white, and --- a band with none in it is siding all the way across. --- --- The FRAME around the glass is found without a second number: among the --- columns that carry no white, the profile that occurs most often is the --- siding (on `t1_s01`, ten columns of thirty-six), and the window block is --- grown outward from the glass for as long as the next column is not that --- profile. That takes in the grey surround and the two courses beside it and --- stops at the first real siding column -- exactly, with nothing tuned. -local bandSplitCache = {} -local function splitWallBand(path, band, tw, th) - if not (path and band and tw and th) then return nil end - local key = ("%s|%d|%d|%d|%d"):format(path, - band.uLo * 16, band.uHi * 16, band.vLo * 16, band.vHi * 16) - local hit = bandSplitCache[key] - if hit ~= nil then return hit or nil end - - -- `Assets.image` hands back a placeholder for a path that is not there -- - -- that is what buried the indoor shadows for a whole pass -- so the pixels - -- are read with the call that RAISES instead. - local okData, data = pcall(function() - return love.image.newImageData(Assets.resolve(path)) - end) - if not (okData and data) then bandSplitCache[key] = false return nil end - - local u0, v0 = math.floor(band.uLo), math.floor(band.vLo) - local w = math.floor(band.uHi - band.uLo + 0.5) - local h = math.floor(band.vHi - band.vLo + 0.5) - if w < 2 or h < 1 then bandSplitCache[key] = false return nil end - - local profile, white = {}, {} - for i = 0, w - 1 do - local parts, hasWhite = {}, false - for j = 0, h - 1 do - local okPx, r, g, b = pcall(data.getPixel, data, - (u0 + i) % tw, (v0 + j) % th) - if not okPx then bandSplitCache[key] = false return nil end - if r >= 0.999 and g >= 0.999 and b >= 0.999 then hasWhite = true end - parts[#parts + 1] = ("%d,%d,%d"):format(r * 255, g * 255, b * 255) - end - profile[i] = table.concat(parts, ";") - white[i] = hasWhite - end - - local first, last - for i = 0, w - 1 do - if white[i] then - if first == nil then first = i end - last = i - end - end - -- No glass: the whole band is siding and tiles as it always did. - if first == nil then bandSplitCache[key] = false return nil end - - local counts, sidingProfile, sidingCount = {}, nil, 0 - for i = 0, w - 1 do - if not white[i] then - local n = (counts[profile[i]] or 0) + 1 - counts[profile[i]] = n - if n > sidingCount then sidingCount, sidingProfile = n, profile[i] end - end - end - if not sidingProfile then bandSplitCache[key] = false return nil end - - while first > 0 and profile[first - 1] ~= sidingProfile do first = first - 1 end - while last < w - 1 and profile[last + 1] ~= sidingProfile do last = last + 1 end - - -- The widest unbroken run of siding left over is what the wall is filled - -- with. Courses are horizontal, so any slice of them tiles invisibly. - local bestLo, bestHi, runLo = nil, nil, nil - for i = 0, w do - local siding = (i < w) and (profile[i] == sidingProfile) or false - if siding and runLo == nil then runLo = i end - if not siding and runLo ~= nil then - if bestLo == nil or (i - runLo) > (bestHi - bestLo + 1) then - bestLo, bestHi = runLo, i - 1 - end - runLo = nil - end - end - if bestLo == nil then bandSplitCache[key] = false return nil end - - local out = { - fillLo = band.uLo + bestLo, fillHi = band.uLo + bestHi + 1, - featLo = band.uLo + first, featHi = band.uLo + last + 1, - } - bandSplitCache[key] = out - return out -end - --- CLOSE A BUILDING'S MISSING BACK. --- --- Requested: *"the backs of buildings are blank which is normal for the rom but --- we should find a way to fill the back with a texture that fits in"*, and --- later *"they should use textures from the building they are a part of so they --- look natural"*. --- --- The cartridge really does omit it -- the camera only ever looks north, so the --- north face is never seen and was never modelled. Measured on Twinleaf's own --- house `t1_h01`: 158 triangles, **0 facing north**, against 50 south, 43 east, --- 41 west and 24 up. `c1_b02b` has 2 north against 96 south. --- --- EDGE-LOOP CAPPING WAS THE PLAN AND IT DOES NOT WORK HERE. Capping needs a --- boundary loop, and this geometry has no boundary: `t1_h01` has **273 open --- edges of 360** because the walls are separate quads that share no vertices. --- There is no loop to walk. --- --- So the back is built from the building's own WALLS instead: their outline --- gives its shape, their outermost planes give its width and the plane it --- stands on, and one of their faces gives the course it is tiled with. Four --- separate measurements, each with its own note below, and the whole point of --- taking them from the walls is that a back built this way has no numbers of --- its own to be wrong. --- --- Right for a boxy house, which is what Sinnoh's are; an L-shaped building --- gets a flat back across its extent rather than a fitted one, and that is the --- honest limit of a single plane. --- --- WHAT THIS COSTS, measured: with the back on, Twinleaf drawn at the cartridge --- rung is pixel-identical to Twinleaf without it -- 0 differing pixels of --- 307,200 -- and the third-person view from the FRONT differs by 21, while the --- view from BEHIND differs by 15.7 per cent. A control that changes nothing --- where nothing should change and everything where it should. -local function addBackWall(self) - local north, south = 0, 0 - local widest, widestArea = nil, -1 - for _, shape in ipairs(self.shapes) do - north = north + (shape.facing and shape.facing.north or 0) - south = south + (shape.facing and shape.facing.south or 0) - -- THE SHAPE WHOSE WALL COURSE COVERS THE MOST OF THE BUILDING, not the - -- one with the most side-facing triangles. A count is not an area: a - -- flight of steps can carry more east/west triangles than the wall behind - -- it. - local area = shape.wallBand and shape.wallBand.area or -1 - if area > widestArea then widestArea, widest = area, shape end - end - -- ...AND IF NO SHAPE HAS A COURSE, THE BIGGEST PLAIN WALL INSTEAD. - local flat - if not (widest and widest.wallBand) then - local best = -1 - for _, shape in ipairs(self.shapes) do - local fb = shape.flatBand - if fb and shape.image and fb.area > best then - best, widest, flat = fb.area, shape, fb - end - end - end - -- A model with no south face at all is not a building with a missing back. - -- Whether it ALREADY has a back is decided further down, against the - -- opening this would actually fill, because a ratio of triangle counts got - -- that wrong for the two buildings it matters most for. - if south == 0 then return false end - local _ = north - if not (widest and widest.image and (widest.wallBand or flat)) then - return false - end - -- A flat wall is a band whose rectangle is a point: one cell wide, one row - -- tall, the same texel everywhere. Everything below then works unchanged -- - -- the triangle wave has no span to sweep, the sawtooth no height to climb, - -- and `splitWallBand` declines a rect it cannot read a column from. - local band = widest.wallBand - -- THE TEXTURE'S OWN SIZE, because the mesh format wants 0..1 and the band - -- was measured in texels. - -- - -- This is the fault the first version shipped with, and it was visible from - -- ten feet away: a texel count was written straight into a 0..1 coordinate, - -- so a 64-pixel texture repeated sixty-four times faster than it should and - -- every back wall came out as noise. Reported as *"doesnt seem to be using - -- the right scale of texture"* -- which it was not, by a factor of `tw`. - local okW, tw = pcall(widest.image.getWidth, widest.image) - local okH, th = pcall(widest.image.getHeight, widest.image) - if not (okW and okH and tw and th and tw > 0 and th > 0) then return false end - - local lo = { math.huge, math.huge, math.huge } - local hi = { -math.huge, -math.huge, -math.huge } - for _, shape in ipairs(self.shapes) do - if shape.lo and shape.hi then - for k = 1, 3 do - if shape.lo[k] < lo[k] then lo[k] = shape.lo[k] end - if shape.hi[k] > hi[k] then hi[k] = shape.hi[k] end - end - end - end - if lo[1] > hi[1] or lo[2] > hi[2] then return false end - if not band then - band = { uLo = flat.u, uHi = flat.u, vLo = flat.v, vHi = flat.v, - vAtFoot = flat.v, yFoot = lo[2], - width = (hi[1] - lo[1]) + 1, height = (hi[2] - lo[2]) + 1, - area = flat.area } - end - - -- A FLAT PANEL IS NOT A BUILDING WITH A MISSING BACK. - -- - -- `door01` passed the north/south test -- a door faces south and has no - -- north side, exactly like a house -- and would have got a wall the size of - -- the door standing behind it. It is 3.9 deep against 19.5 wide; a Twinleaf - -- house is 44 deep against 67. What separates them is DEPTH: a thing with - -- no depth has no inside to close, and its "back" is the wall it is stuck to. - local width = hi[1] - lo[1] - local height = hi[2] - lo[2] - local depth = hi[3] - lo[3] - local across = (width > height) and width or height - if across < 1e-6 or depth < across * 0.25 then return false end - - -- IT FOLLOWS THE BUILDING'S SILHOUETTE, not its bounding box. - -- - -- The first version was one rectangle across the whole box, and the - -- CARTRIDGE view changed by 3,887 pixels when it should not have changed at - -- all: a gabled roof slopes down to the eaves, so a rectangle reaching the - -- RIDGE height sticks out past the roof on both sides, and those corners - -- showed as two red triangles over every house. - -- - -- THE OUTLINE COMES FROM THE WALL TRIANGLES, NOT FROM WHOLE SHAPES. - -- - -- Three separate adjustments -- inset the height, move to the wall line, - -- shrink the box -- each moved the stray-pixel count by about ten, which is - -- the signature of guessing at a cause rather than finding one. The cause - -- is that the ROOF overhangs the walls on every axis, so a silhouette that - -- includes it puts a wall out under the eaves on all sides at once. - -- - -- Excluding whole shapes whose faces mostly point up was the first attempt - -- and it excludes nothing on the buildings that matter: `t1_h01` is ONE - -- shape carrying its walls and its roof together. `wallTris` lists the - -- triangles that are not up- or down-facing, so the silhouette is the walls' - -- and the gable end's -- which is exactly the outline a back should fill. - -- NORTH IS -Z -- AND THE WALL LINE IS NOT THE BOX'S NORTH EDGE. - -- - -- The box's north extent is the ROOF OVERHANG, which reaches further north - -- than the walls do. Placing the back there put it out under the eaves - -- where the roof does not cover it, and it showed from the front as thin - -- vertical slivers at each gable corner -- 259 stray pixels on a view that - -- must not change. Tucking it half a unit lower barely moved that (253), - -- which is what said the fault was depth and not height. - -- - -- Nor is it the SHAPE's north edge, which was the next guess and no better: - -- `t1_h01` is one shape carrying its walls and its roof, so its north edge - -- IS the overhang, at -22.00. - -- - -- Nor is it the plane of the faces wearing the wall COURSE, which was the - -- guess after that and is the one that shipped. Reported from play: - -- *"theyre a bit too far inside of the house rather than fitting on the - -- back"*, and that is exactly right -- `t1_h01`'s chosen course is worn by - -- two quads that stop at -13.75 while the walls carry on to -19.75, so the - -- back stood six units inside its own house. A course is a texture, and - -- where a texture stops is not where a building stops. - -- - -- It is the northmost WALL FACE: every triangle that is not up- or - -- down-facing, which is the same set the silhouette is drawn from. Measured - -- over six buildings it agrees with the northmost east/west face every time - -- and sits 1 to 3 units south of the bounding box -- the roof overhang, - -- which is the one thing it has to exclude. - -- ...AND IT IS NOT SIMPLY THE NORTHMOST WALL FACE EITHER. - -- - -- `c1_b01a`'s northmost wall stands two units proud of the house and is a - -- PLINTH: the cross-section there is eight triangles reaching 11.30 where - -- the building is 102 tall, and a back drawn to it came out with no height - -- and was dropped. So the wall planes are tried from the north inward and - -- the first one where the building is still at least half its own height is - -- taken. There is no slope to sit on: the rejected plane is 11 per cent of - -- the height and the accepted one 96, while every other building measured - -- passes at its very first plane at 100. - local candidates, seenZ = {}, {} - for _, shape in ipairs(self.shapes) do - for _, zw in ipairs(shape.wallZs or {}) do - local q = math.floor(zw * 16 + 0.5) - if not seenZ[q] then seenZ[q] = true; candidates[#candidates + 1] = zw end - end - end - table.sort(candidates) - local enough = lo[2] + (hi[2] - lo[2]) * 0.5 - local wallZ - for _, zc in ipairs(candidates) do - local top = -math.huge - for _, shape in ipairs(self.shapes) do - local pos, tri = shape.outline, shape.tris - for t = 1, (pos and tri and #tri or 0), 3 do - local pa, pb, pc = pos[tri[t]], pos[tri[t + 1]], pos[tri[t + 2]] - if pa and pb and pc then - local zl = (pa[3] < pb[3] and pa[3] or pb[3]) - if pc[3] < zl then zl = pc[3] end - local zh = (pa[3] > pb[3] and pa[3] or pb[3]) - if pc[3] > zh then zh = pc[3] end - if zl <= zc and zh >= zc then - if pa[2] > top then top = pa[2] end - if pb[2] > top then top = pb[2] end - if pc[2] > top then top = pc[2] end - end - end - end - end - if top >= enough then wallZ = zc break end - end - if not wallZ then - for _, shape in ipairs(self.shapes) do - local zw = shape.wallZLo - if zw and (wallZ == nil or zw < wallZ) then wallZ = zw end - end - end - if not wallZ then wallZ = (widest.lo and widest.lo[3]) or lo[3] end - -- The cross-section is taken at the wall plane itself and the quad is drawn - -- a twentieth of a unit south of it. Sectioning at the drawn plane instead - -- drops every flat face that ENDS at the wall line -- zero z extent, so it - -- crosses nothing -- and `c1_b01a`'s back collapsed to no height at all. - local zSection = wallZ - local z = wallZ + 0.05 - - -- ...AND IT IS TUCKED A LITTLE UNDER THE ROOFLINE. - -- - -- The silhouette is sampled on a sixteenth-of-a-unit grid, so a sloping - -- gable leaves a staircase whose corners can stand a fraction proud of the - -- real roof edge. Half a unit of inset puts the wall under the roof rather - -- than through it, and is invisible from behind because the roof covers it. - local INSET = 0.5 - - local xs, seen = {}, {} - for _, shape in ipairs(self.shapes) do - local pos, tri = shape.outline, shape.tris - for t = 1, (pos and tri and #tri or 0), 3 do - local pa, pb, pc = pos[tri[t]], pos[tri[t + 1]], pos[tri[t + 2]] - if pa and pb and pc then - local zlo = (pa[3] < pb[3] and pa[3] or pb[3]) - if pc[3] < zlo then zlo = pc[3] end - local zhi = (pa[3] > pb[3] and pa[3] or pb[3]) - if pc[3] > zhi then zhi = pc[3] end - if zlo <= zSection and zhi >= zSection then - for e = 1, 3 do - local v = (e == 1 and pa) or (e == 2 and pb) or pc - local key = math.floor(v[1] * 16 + 0.5) - if not seen[key] then seen[key] = true; xs[#xs + 1] = key end - end - end - end - end - end - if #xs < 2 then return false end - table.sort(xs) - - - -- THE WALL IS TILED WITH ITS OWN COURSE, not stretched across the span. - -- - -- `t1_h01`'s side wall is TWO quads that share an edge, 11.75 units wide - -- each, and the second one runs its u backwards -- 28 texels forwards, then - -- 28 texels back. The cartridge mirrors the course rather than repeating - -- it, which is why the wall has no visible seam down its middle. The back - -- is tiled the same way, so it matches the sides it joins at the corner. - -- - -- u is therefore a triangle wave, and CONTINUOUS at every band edge, so one - -- value per x is well defined no matter which cell a breakpoint is read in. - -- The wall's own x extent, from the silhouette rather than the box -- the - -- box's is the roof's. - local wallXLo, wallXHi = xs[1] / 16, xs[#xs] / 16 - -- WHAT THE WALL IS FILLED WITH: the band's siding, with its window taken - -- out and set aside to be stamped once. `splitWallBand` returns nil for a - -- band that is siding all the way across, and then the whole band tiles as - -- before. - local split = splitWallBand(widest.imagePath, band, tw, th) - local density = (band.uHi - band.uLo) / band.width - local tileULo = split and split.fillLo or band.uLo - local tileUHi = split and split.fillHi or band.uHi - local tileW = (tileUHi - tileULo) / density - if not (tileW > 1e-6) then tileULo, tileUHi, tileW = band.uLo, band.uHi, band.width end - local uSpan = tileUHi - tileULo - local function uAt(x) - local t = (x - wallXLo) / tileW - local cell = math.floor(t) - local f = t - cell - if cell % 2 ~= 0 then f = 1 - f end - return (tileULo + uSpan * f) / tw - end - -- v REPEATS rather than mirroring, because a course of siding read upside - -- down is not a course of siding. A sawtooth is discontinuous at each row - -- edge, so it is evaluated against a KNOWN row instead of from y alone, and - -- the rows are cut exactly on those edges below. - local vFoot = band.vAtFoot - local vHead = (vFoot == band.vHi) and band.vLo or band.vHi - local function vAt(row, y) - local f = (y - (band.yFoot + row * band.height)) / band.height - if f < 0 then f = 0 elseif f > 1 then f = 1 end - return (vFoot + (vHead - vFoot) * f) / th - end - - -- THE BACK IS NO WIDER THAN THE SIDES IT JOINS. - -- - -- The silhouette is the building's cross-section and it includes the FRONT - -- facade, which on `t1_h01` reaches 32 units either side while the side - -- walls stand at 29. Three units of back wall then stood proud of each - -- corner and showed from the front as a red sliver down both houses -- - -- 3,805 pixels on a view that must not change. - -- - -- Clamping to the outermost east/west planes either does nothing or pulls - -- in: measured over twelve placed buildings it never widened one. - do - -- ...AND ONLY THE SIDE WALLS THAT REACH THE BACK PLANE. - -- - -- A building with a wide front and a narrow back has no single width, and - -- taking the widest anywhere left a back wall standing several pixels - -- proud of the near corner of the house closest to the camera -- 1,592 of - -- the 1,757 stray pixels left in the front view came from that one - -- building. - -- A CLAMP THAT MAY DECLINE. - -- - -- The section's own width is the roof's, so it has to be pulled in to the - -- walls -- but only where the wall being pulled to is the SAME wall. The - -- side planes crossing the back are tried first and the outermost planes - -- second, and each side takes the first candidate that moves it by no more - -- than a tenth of the section's width. - -- - -- Insisting on the crossing planes was tried and it is far too strong: - -- `t1_s01`, `t1_s02` and `t2_s02` have nothing at the back plane but a - -- narrow pair of faces, and their backs collapsed to a 7.5-unit sliver on - -- a 90-unit house, while `t2_s01`'s lost its whole right half. - local secLo, secHi = xs[1] / 16, xs[#xs] / 16 - local limit = (secHi - secLo) * 0.1 - local crossLo, crossHi, anyLo, anyHi - for _, shape in ipairs(self.shapes) do - local pl = shape.sidePlanes - for i = 1, (pl and #pl or 0), 3 do - local px = pl[i] - anyLo = (anyLo == nil or px < anyLo) and px or anyLo - anyHi = (anyHi == nil or px > anyHi) and px or anyHi - if pl[i + 1] <= zSection and pl[i + 2] >= zSection then - crossLo = (crossLo == nil or px < crossLo) and px or crossLo - crossHi = (crossHi == nil or px > crossHi) and px or crossHi - end - end - end - -- - -- The tenth-of-the-width test chooses BETWEEN the two candidates; it does - -- not decide whether to clamp at all. A back wider than every side wall - -- the building owns is wrong however far apart they are, so the outermost - -- plane is taken unconditionally when neither candidate is close -- which - -- is what was left showing as a four-pixel strip down the near corner of - -- the house closest to the camera, on a building whose roof overhangs by - -- more than a tenth of its width. - local function pick(current, cross, any, inward) - if cross and (cross - current) * inward > 0 - and (cross - current) * inward <= limit then return cross end - if any and (any - current) * inward > 0 - and (any - current) * inward <= limit then return any end - if any and (any - current) * inward > 0 then return any end - return current - end - local sxLo = pick(secLo, crossLo, anyLo, 1) - local sxHi = pick(secHi, crossHi, anyHi, -1) - -- ...AND THE CLADDING'S OWN PLANE BEATS THE OUTERMOST FACE WHEN THEY ARE - -- THE SAME WALL. - -- - -- `t1_h01`'s outermost east/west faces stand at 29.00 and its clapboard at - -- 28.00: a one-unit strip of trim on the same wall. A back drawn to 29 - -- reaches a unit past the cladding and is visible from the front as a - -- clapboard sliver down the house's corner, which is what 846 stray pixels - -- looked like when cropped and enlarged. - -- - -- A tenth of the building's width separates the two cases cleanly on the - -- twelve buildings placed here: where the course's plane and the outermost - -- plane are the same wall they differ by 0.04 to 5 per cent (t1_h01 1.7, - -- c1_b01 3, c1_school1 2 to 3.5, t1_s01 0.7 to 5), and where they are - -- genuinely different walls -- a course worn only by a narrow pair of - -- faces, or a wing that the course does not reach -- they differ by 14, 37 - -- and 45. Nothing measured falls between. - if sxLo and sxHi and sxHi > sxLo and band.xLo and band.xHi then - local span = (sxHi - sxLo) * 0.1 - if band.xLo > sxLo and (band.xLo - sxLo) <= span then sxLo = band.xLo end - if band.xHi < sxHi and (sxHi - band.xHi) <= span then sxHi = band.xHi end - end - if sxLo and sxHi and sxHi > sxLo then - local kLo = math.floor(sxLo * 16 + 0.5) - local kHi = math.floor(sxHi * 16 + 0.5) - if kLo > xs[1] or kHi < xs[#xs] then - local keep = {} - for _, k in ipairs(xs) do - if k > kLo and k < kHi then keep[#keep + 1] = k end - end - table.insert(keep, 1, kLo) - keep[#keep + 1] = kHi - xs = keep - end - wallXLo, wallXHi = kLo / 16, kHi / 16 - end - end - - -- Cut the wall on the band edges as well as the silhouette's corners, so - -- every cell carries one unbroken run of the course. - do - local extra = {} - local k = 1 - while true do - local x = wallXLo + k * tileW - if x >= wallXHi - 1e-6 then break end - local key = math.floor(x * 16 + 0.5) - if not seen[key] then seen[key] = true; extra[#extra + 1] = key end - k = k + 1 - if k > 4096 then break end - end - for _, key in ipairs(extra) do xs[#xs + 1] = key end - if #extra > 0 then table.sort(xs) end - end - - -- THE ROOFLINE, AS THE UPPER ENVELOPE OF THE CROSS-SECTION AT z. - -- - -- Taking the highest VERTEX at each sampled x is not the outline -- it is a - -- sample of it, and the two disagree wherever a triangle spans an x that - -- carries no vertex of its own. Looked at from behind, that showed as two - -- pale spikes standing above `t1_h01`'s gable and a dark wedge beside them - -- where the wall stopped short and the roof's underside showed through. - -- - -- Asking each triangle how high it reaches AT this x answers the same - -- question exactly: the envelope's breakpoints are triangle corners, so - -- sampling at every corner and at every band edge loses nothing between - -- them. - -- - -- AND ONLY THE TRIANGLES THAT CROSS THE BACK PLANE, which is the fix that - -- the raised inset and the three rear-plane choices could not be: taking the - -- outline over the WHOLE model gives the building's tallest cross-section - -- anywhere, and Twinleaf's two-storey houses are tall at the FRONT and low - -- at the back. Their backs were drawn to the front block's height and stood - -- up behind their own roofs -- 312 stray pixels in the cartridge view, - -- entirely on those houses, with the single-storey ones clean. Raising the - -- inset eightfold only took it to 217, which is what said the height was not - -- the fault. - -- - -- The roof is included here where the walls are not. Over the hole the back - -- is closing there IS no wall, so the roof's underside is the only thing - -- that states the shape of the opening -- and it states it exactly: on - -- `t1_h01` the cross-section reads 72.50 at the ridge and 50.63 at the - -- cladding line, which is the gable. - local column = {} - do - local kept = {} - for _, key in ipairs(xs) do - -- `best` is the section's answer; `spare` is the whole model's, used - -- only where the section has nothing to say. - -- - -- `c1_b01a`'s northmost wall face is a low plinth standing two units - -- proud of the house, so the section at the back plane is eight - -- triangles reaching 11.30 where the building is 98 tall -- and the back - -- came out with no height at all and was dropped. Where the section is - -- silent the model's own outline answers instead, which is what this did - -- before the section existed. - local x, best, spare = key / 16, nil, nil - for _, shape in ipairs(self.shapes) do - local pos, tri = shape.outline, shape.tris - for t = 1, (pos and tri and #tri or 0), 3 do - local pa, pb, pc = pos[tri[t]], pos[tri[t + 1]], pos[tri[t + 2]] - if pa and pb and pc then - local xa, xb, xc = pa[1], pb[1], pc[1] - local xlo = (xa < xb and xa or xb); if xc < xlo then xlo = xc end - local xhi = (xa > xb and xa or xb); if xc > xhi then xhi = xc end - local zlo = (pa[3] < pb[3] and pa[3] or pb[3]) - if pc[3] < zlo then zlo = pc[3] end - local zhi = (pa[3] > pb[3] and pa[3] or pb[3]) - if pc[3] > zhi then zhi = pc[3] end - local crosses = (zlo <= zSection and zhi >= zSection) - if x >= xlo - 0.03125 and x <= xhi + 0.03125 then - -- the three edges, and any corner standing on this x - for e = 1, 3 do - local p = (e == 1 and pa) or (e == 2 and pb) or pc - local q = (e == 1 and pb) or (e == 2 and pc) or pa - local dx = q[1] - p[1] - if dx > 1e-6 or dx < -1e-6 then - local f = (x - p[1]) / dx - if f >= 0 and f <= 1 then - local y = p[2] + (q[2] - p[2]) * f - if crosses then - if best == nil or y > best then best = y end - elseif spare == nil or y > spare then spare = y - end - end - end - if p[1] >= x - 0.03125 and p[1] <= x + 0.03125 then - if crosses then - if best == nil or p[2] > best then best = p[2] end - elseif spare == nil or p[2] > spare then spare = p[2] - end - end - end - end - end - end - end - if best == nil then best = spare end - if best then - local y = best - INSET - column[key] = (y > lo[2]) and y or lo[2] - kept[#kept + 1] = key - end - end - xs = kept - end - if #xs < 2 then return false end - - -- IS THERE ALREADY A BACK HERE? Asked against the opening, not by counting - -- triangles. - -- - -- The old test -- north-facing triangles must be under a tenth of the - -- south-facing ones -- threw out the Pokemon Centre and the Poke Mart, the - -- two buildings in Sinnoh a player walks behind most, and it threw them out - -- for the wrong reason twice over: their roofs contributed most of those - -- "north" faces, and what vertical north wall they do have covers only part - -- of the opening. Reported: *"the pokemarts, and pokemon centers are - -- missing backs still"*. - -- - -- Comparing like with like instead: the wall area that already faces north - -- AT the back plane, against the area this would add. A model whose back is - -- genuinely modelled covers its own opening and is left alone; one that - -- covers a third of it gets the rest. - do - local opening = 0 - for i = 1, #xs - 1 do - local w = (xs[i + 1] - xs[i]) / 16 - local h0 = column[xs[i]] - lo[2] - local h1 = column[xs[i + 1]] - lo[2] - opening = opening + w * (h0 + h1) * 0.5 - end - local covered = 0 - for _, shape in ipairs(self.shapes) do - local nw = shape.northWalls - for i = 1, (nw and #nw or 0), 2 do - if nw[i] <= wallZ + 1 and nw[i] >= wallZ - 1 then - covered = covered + nw[i + 1] - end - end - end - if opening <= 1e-6 or covered >= opening * 0.9 then return false end - end - - local rowLo = math.floor((lo[2] - band.yFoot) / band.height) - local vertices, map = {}, {} - for i = 1, #xs - 1 do - local x0, x1 = xs[i] / 16, xs[i + 1] / 16 - local y0, y1 = column[xs[i]], column[xs[i + 1]] - local top = (y0 > y1) and y0 or y1 - local rowHi = math.floor((top - band.yFoot) / band.height) - for row = rowLo, rowHi do - local rb = band.yFoot + row * band.height - local rt = rb + band.height - -- Each corner is clipped to the roofline above it and the ground below, - -- so a gable ends as a wedge rather than a staircase. - local function clip(y, cap) - if y < lo[2] then return lo[2] end - if y > cap then return cap end - return y - end - local a0, a1 = clip(rb, y0), clip(rt, y0) - local b0, b1 = clip(rb, y1), clip(rt, y1) - if (a1 - a0) > 1e-4 or (b1 - b0) > 1e-4 then - local ua, ub = uAt(x0), uAt(x1) - local base = #vertices - vertices[#vertices + 1] = - { x0, a0, z, ua, vAt(row, a0), 1, 1, 1, 1, 0, 0, 0 } - vertices[#vertices + 1] = - { x1, b0, z, ub, vAt(row, b0), 1, 1, 1, 1, 0, 0, 0 } - vertices[#vertices + 1] = - { x1, b1, z, ub, vAt(row, b1), 1, 1, 1, 1, 0, 0, 0 } - vertices[#vertices + 1] = - { x0, a1, z, ua, vAt(row, a1), 1, 1, 1, 1, 0, 0, 0 } - map[#map+1] = base + 1; map[#map+1] = base + 2; map[#map+1] = base + 3 - map[#map+1] = base + 1; map[#map+1] = base + 3; map[#map+1] = base + 4 - end - end - end - -- ONE WINDOW, where the cartridge puts one. - -- - -- Stamped rather than tiled, at the height the side wall carries it and - -- centred across the back, with the band's own full v range so its courses - -- run straight into the siding either side of it. It sits a fiftieth of a - -- unit north of the wall, which is in front of it from every angle that can - -- see the back at all. - if split and #vertices > 0 then - local featW = (split.featHi - split.featLo) / density - local mid = (wallXLo + wallXHi) * 0.5 - local fx0, fx1 = mid - featW * 0.5, mid + featW * 0.5 - local fy0 = band.yFoot - local fy1 = fy0 + band.height - -- Only where there is wall to put it on: it must fit between the corners - -- and stand below the roofline at both of its own edges. - local room = true - if not (featW > 1e-6 and fx0 > wallXLo and fx1 < wallXHi and fy0 >= lo[2]) then - room = false - else - for _, key in ipairs(xs) do - local kx = key / 16 - if kx >= fx0 - 1e-6 and kx <= fx1 + 1e-6 and column[key] < fy1 then - room = false - break - end - end - end - if room then - local fz = z - 0.02 - local ua, ub = split.featLo / tw, split.featHi / tw - local va, vb = vAt(0, fy0), vAt(0, fy1) - local base = #vertices - vertices[#vertices + 1] = { fx0, fy0, fz, ua, va, 1, 1, 1, 1, 0, 0, 0 } - vertices[#vertices + 1] = { fx1, fy0, fz, ub, va, 1, 1, 1, 1, 0, 0, 0 } - vertices[#vertices + 1] = { fx1, fy1, fz, ub, vb, 1, 1, 1, 1, 0, 0, 0 } - vertices[#vertices + 1] = { fx0, fy1, fz, ua, vb, 1, 1, 1, 1, 0, 0, 0 } - map[#map+1] = base + 1; map[#map+1] = base + 2; map[#map+1] = base + 3 - map[#map+1] = base + 1; map[#map+1] = base + 3; map[#map+1] = base + 4 - end - end - - if #vertices == 0 then return false end - local okMesh, mesh = - pcall(love.graphics.newMesh, FORMAT, vertices, "triangles", "static") - if not (okMesh and mesh) then return false end - pcall(mesh.setVertexMap, mesh, map) - mesh:setTexture(widest.image) - self.shapes[#self.shapes + 1] = { - mesh = mesh, name = "backWall", index = nil, - material = widest.material, image = widest.image, - lo = { lo[1], lo[2], z }, hi = { hi[1], hi[2], z }, - } - return true -end - --- addGroundShadow(model, path) -> did it add one? --- --- Requested: *"implement shadows for indoor objects"*. --- --- THE CARTRIDGE DOES NOT HAVE THESE, and that was measured before writing a --- line of it. 153 of the 590 prop models carry real shadow geometry -- a --- material named `h_kage`, which this file already gives a reduced alpha -- --- but they are the OUTDOOR props. Of the models actually placed on maps, the --- most common are interior furniture and every one of them carries none: --- `ref01` 203 placements, `sofa01` 109, `table02` 82, `chair03` 78, `bed_h01` --- 57, `plant01` 34 -- **zero shadow shapes each**. --- --- So this is GENERATED, not extracted, and it is only added where the --- cartridge left a gap: a model that already has a `kage` shape keeps its own. --- --- Added as a SHAPE on the model rather than as a draw in the ground pass, --- because the props are drawn from four different places (the bake, the live --- pass, the canopy pass and the free pass) and a shadow that has to be added --- to each is a shadow that will be missing from one. -local function addGroundShadow(self, path) - for _, shape in ipairs(self.shapes) do - if tostring(shape.material or ""):lower():find("kage", 1, true) then - return false - end - end - local lo = { math.huge, math.huge, math.huge } - local hi = { -math.huge, -math.huge, -math.huge } - for _, shape in ipairs(self.shapes) do - if shape.lo and shape.hi then - for k = 1, 3 do - if shape.lo[k] < lo[k] then lo[k] = shape.lo[k] end - if shape.hi[k] > hi[k] then hi[k] = shape.hi[k] end - end - end - end - -- A model whose shapes reported no box at all has nothing to sit under. - if lo[1] > hi[1] or lo[3] > hi[3] then return false end - local okImage, image = pcall(Assets.image, path) - if not (okImage and image) then return false end - - -- THE TRANSLUCENCY IS CARRIED ON THE VERTEX, not left to `shapeAlpha`. - -- - -- Measured: with the alpha coming only from the material name, the shadow - -- painted 0.161,0.192,0.259 over a PALE floor and the identical colour over - -- a DARK one -- the same patch either way, which is what opaque looks like. - -- A plain 29%-alpha rectangle in the same harness reads 0.608 over that pale - -- floor, so blending itself is working; the alpha simply was not arriving. - -- - -- The shader multiplies `texel * colour` and `colour` is LOVE's vertex - -- colour times the draw colour, so putting it here makes it independent of - -- what `shapeAlpha` decides -- and a generated shadow should not depend on - -- being named well enough to be recognised. - local a = SHADOW_ALPHA_FRACTION - local y = lo[2] + SHADOW_LIFT - local vertices = { - { lo[1], y, lo[3], 0, 0, 1, 1, 1, a, 0, 0, 0 }, - { hi[1], y, lo[3], 1, 0, 1, 1, 1, a, 0, 0, 0 }, - { hi[1], y, hi[3], 1, 1, 1, 1, 1, a, 0, 0, 0 }, - { lo[1], y, hi[3], 0, 1, 1, 1, 1, a, 0, 0, 0 }, - } - local okMesh, mesh = - pcall(love.graphics.newMesh, FORMAT, vertices, "triangles", "static") - if not (okMesh and mesh) then return false end - pcall(mesh.setVertexMap, mesh, { 1, 2, 3, 1, 3, 4 }) - mesh:setTexture(image) - self.shapes[#self.shapes + 1] = { - mesh = mesh, name = "groundShadow", - -- NO INDEX, so `pose[shape.index]` finds nothing and the shadow is placed - -- by the model's own matrix. A posed model -- most of them pose to - -- identity -- would otherwise need a joint this shape does not belong to. - index = nil, - -- NAMED `kage` on purpose: `shapeAlpha` reads that and gives it the - -- cartridge's own shadow translucency, so a generated shadow is exactly as - -- dark as an extracted one. - material = "kage", - image = image, - lo = { lo[1], y, lo[3] }, hi = { hi[1], y, hi[3] }, - } - return true -end - -function Gen4Model.new(record) - if type(record) ~= "table" or type(record.shapes) ~= "table" then return nil end - if not ensureShader() then return nil end - - local self = setmetatable({}, Gen4Model) - self.name = record.name - self.posScale = record.posScale or 1 - self.bounds = record.bounds - self.shapes = {} - -- The model's own node transforms and the bytecode that arranges them. A - -- model whose nodes are all identity -- most of them -- poses to identity - -- and costs nothing; the briefcase's do the work. - self.nodes = record.nodes or {} - self.ops = record.ops or {} - - for _, shape in ipairs(record.shapes) do - local image - if shape.image then - local ok, loaded = pcall(Assets.image, shape.image) - if ok and loaded then - image = loaded - -- Nearest, always: these are 16 and 64 pixel textures on a model that - -- will be drawn several times their own size, and smoothing them turns - -- a Poke Ball's seam into a smear. - image:setFilter("nearest", "nearest") - image:setWrap("repeat", "repeat") - end - end - - -- The texture's own size is what turns a coordinate in sixteenths of a - -- texel into the 0-1 the sampler wants. Without an image there is nothing - -- to divide by and the shape draws untextured, which is what an untextured - -- material means anyway. - local tw, th = 1, 1 - if image then tw, th = image:getDimensions() end - - local count = shape.vertexCount or 0 - local vertices = {} - -- Kept alongside, because classifying a card needs the whole triangle and - -- the loop below sees one vertex at a time. - -- Also collected for the back-wall cap, which needs the face normals to - -- know whether a back is missing and which wall is a plain side. - local positions = (record.billboard or record.capBack) and {} or nil - local grassPending - local uLo, uHi = math.huge, -math.huge - -- THE STRIDE THIS CACHE WAS WRITTEN WITH, measured off the record rather - -- than assumed from the constant. - -- - -- The vertex grew from 14 bytes to 16 when the normal was added, and a - -- cache imported before that change is still on disk in every install that - -- has not re-imported. Reading a 14-byte buffer at a 16-byte stride does - -- not fail -- it misaligns EVERY vertex after the first, so the world comes - -- back as confetti with no error anywhere. Dividing the buffer by the - -- vertex count is the one question that has a right answer on both. - local stride = VERTEX_BYTES - if count > 0 and shape.vertices then - local measured = math.floor(#shape.vertices / count) - if measured == LEGACY_VERTEX_BYTES or measured == VERTEX_BYTES then - stride = measured - end - end - local lit = (stride >= VERTEX_BYTES) - -- THE LIGHT, if this caller brought one. `shade(nx, ny, nz)` answers a - -- multiplier per channel and is built by `Gen4Shade` from the map's own - -- area-light template -- which is why it arrives as an argument rather - -- than being looked up here: this file is handed shapes by the ground - -- (which knows the map) and by screens that have no map at all, and a - -- model with no light must keep drawing exactly as it did. - local shade = record.shade - -- The shape's own box, kept while the vertices are still here. It is what - -- `framing` works from: the header states a bounding box too, but it does - -- not agree with the geometry on two thirds of this cartridge's models, so - -- the measured one is the one to trust. - local lo = { math.huge, math.huge, math.huge } - local hi = { -math.huge, -math.huge, -math.huge } - for i = 0, count - 1 do - local at = i * stride - local r, g, b = shape.vertices:byte(at + 11, at + 13) - r, g, b = r or 255, g or 255, b or 255 - if shade and lit then - -- The normal, back out of its three signed bytes. Absent on a cache - -- imported before the stride changed, and `s8` answers nil there -- - -- which leaves the colour alone rather than multiplying it by a - -- normal that is really the first two bytes of the next vertex. - local nx, ny, nz = s8(shape.vertices, at + 13), - s8(shape.vertices, at + 14), - s8(shape.vertices, at + 15) - if nx and ny and nz then - local lr, lg, lb = shade(nx, ny, nz) - r = r * (lr or 1) - g = g * (lg or 1) - b = b * (lb or 1) - end - end - local x = s16(shape.vertices, at) / FX16 * self.posScale - local y = s16(shape.vertices, at + 2) / FX16 * self.posScale - local z = s16(shape.vertices, at + 4) / FX16 * self.posScale - if x < lo[1] then lo[1] = x end - if y < lo[2] then lo[2] = y end - if z < lo[3] then lo[3] = z end - if x > hi[1] then hi[1] = x end - if y > hi[2] then hi[2] = y end - if z > hi[3] then hi[3] = z end - vertices[i + 1] = { - x, y, z, - s16(shape.vertices, at + 6) / UV_UNITS / tw, - s16(shape.vertices, at + 8) / UV_UNITS / th, - -- ...and back to the 0..1 the float attribute wants. - r / 255, g / 255, b / 255, 1, - -- filled in below, once the triangles say which vertices are cards - 0, 0, 0, - } - if positions then - -- the TEXEL coordinate, not the 0..1 one: the strip test compares it - -- against the texture's own width, so it has to be in texture units. - local texel = s16(shape.vertices, at + 6) / UV_UNITS - -- ...AND THE TEXEL V, which the back wall's band needs. Kept beside - -- the u rather than derived later because this is the only place the - -- raw vertex is still in hand. - positions[i + 1] = { x, y, z, texel, - s16(shape.vertices, at + 8) / UV_UNITS } - if texel < uLo then uLo = texel end - if texel > uHi then uHi = texel end - end - end - - -- WHICH OF THESE ARE CARDS, and where each one turns. - -- - -- Only when the caller asked: `record.billboard` is set for terrain chunks - -- and NOT for buildings, and that is measured rather than tidy. Run over - -- `build_model.narc` the same classifier catches 184 triangles -- a school - -- roof, a fountain, a shop front -- that happen to sit at the tree's lean - -- and would then swing to face the camera. The cartridge's trees live in - -- the terrain, so that is where the rule is allowed to apply. - if positions then - -- ONLY WHEN THE TEXTURE REALLY LOADED. `tw` falls back to 1, and a - -- width of 1 would make the strip test reject every card with a u span - -- over 1.5 texels -- which is all of them. Passing nil there skips the - -- test and billboards on the geometry alone, which is what a cache with - -- no textures should do rather than silently doing nothing. - local pivots, cards = - billboardPivots(shape, positions, count, image and tw or nil) - if cards > 0 then - for i = 1, count do - local v, pv = vertices[i], pivots[i] - v[10], v[11], v[12] = pv[1], pv[2], pv[3] - end - self.cards = (self.cards or 0) + cards - end - -- ...AND THE GRASS STANDS UP, where the material says a wild Pokemon - -- lives in it. The flat quad stays: from the cartridge's own camera the - -- card is edge-on and what you see is still the ground tile. - if record.tileUnits and record.tileUnits > 0 - and GRASS_MATERIALS[tostring(shape.texture or "")] then - grassPending = { shape = shape, positions = positions } - end - end - if stride == LEGACY_VERTEX_BYTES and not Gen4Model.saidLegacy then - Gen4Model.saidLegacy = true - Logger.warn("gen4 model: this cache's vertices are %d bytes and carry no " - .. "normal, so nothing can be lit -- the world will draw " - .. "correctly and FLAT until the ROM is imported again", - LEGACY_VERTEX_BYTES) - end - - if #vertices > 0 then - local map = {} - for i = 0, (shape.triangleCount or 0) * 3 - 1 do - local a, b = shape.indices:byte(i * 2 + 1, i * 2 + 2) - -- Back to one-based, which is what setVertexMap takes. - map[i + 1] = (a + b * 256) + 1 - end - - local okMesh, mesh = - pcall(love.graphics.newMesh, FORMAT, vertices, "triangles", "static") - if okMesh and mesh then - if #map > 0 then pcall(mesh.setVertexMap, mesh, map) end - if image then mesh:setTexture(image) end - -- WHICH WAY THIS SHAPE'S FACES POINT, and how densely it is textured. - -- - -- Both are for the back-wall cap: the counts say whether a model has a - -- north side at all, and the density lets a generated wall wear the - -- side wall's texture at the side wall's own scale rather than - -- stretched across the span. - local facing, wallBand, bands, tris, sideXLo, sideXHi, wallZLo - local sidePlanes, wallZs, northArea, southArea, northWalls, flatBand - if record.capBack and positions then - facing = { north = 0, south = 0, east = 0, west = 0, up = 0, down = 0 } - local indices = shape.indices or "" - for t = 0, (shape.triangleCount or 0) - 1 do - local at2 = t * 6 - local a1, a2, b1, b2, c1, c2 = indices:byte(at2 + 1, at2 + 6) - if not c2 then break end - local pa = positions[a1 + a2 * 256 + 1] - local pb = positions[b1 + b2 * 256 + 1] - local pc = positions[c1 + c2 * 256 + 1] - if pa and pb and pc then - -- EVERY triangle is kept, roof included. The back plugs the - -- building's CROSS-SECTION at the back plane, and over the hole - -- it is closing the only thing there is to measure is the roof. - tris = tris or {} - tris[#tris + 1] = a1 + a2 * 256 + 1 - tris[#tris + 1] = b1 + b2 * 256 + 1 - tris[#tris + 1] = c1 + c2 * 256 + 1 - local ux, uy, uz = pb[1]-pa[1], pb[2]-pa[2], pb[3]-pa[3] - local vx, vy, vz = pc[1]-pa[1], pc[2]-pa[2], pc[3]-pa[3] - local nx = uy*vz - uz*vy - local ny = uz*vx - ux*vz - local nz = ux*vy - uy*vx - local len = math.sqrt(nx*nx + ny*ny + nz*nz) - if len > 1e-6 then - nx, ny, nz = nx/len, ny/len, nz/len - local ax, ay, az = math.abs(nx), math.abs(ny), math.abs(nz) - local upright = (ay >= ax and ay >= az) - if upright then - facing[ny > 0 and "up" or "down"] = - facing[ny > 0 and "up" or "down"] + 1 - else - -- A WALL VERTEX, and the back's silhouette is drawn from - -- these alone. - -- - -- The outline used to be every vertex of every shape that - -- was not MOSTLY roof, which is a per-shape test -- and - -- `t1_h01` is a single shape carrying its walls and its roof - -- together, so nothing was ever excluded and the silhouette - -- was the ROOF's. The back then reached out under the eaves - -- on both sides and showed from the FRONT as red slivers - -- down each house: 9,742 pixels on a view that must not - -- change at all. - -- - -- Sorting triangles rather than shapes costs nothing here -- - -- the normals are already in hand for `facing` -- and gives - -- the outline of the WALLS, which is the thing a back wall - -- has to match. - -- HOW FAR NORTH THE WALLS REACH. North is -z. - local zw = pa[3] - if pb[3] < zw then zw = pb[3] end - if pc[3] < zw then zw = pc[3] end - if wallZLo == nil or zw < wallZLo then wallZLo = zw end - wallZs = wallZs or {} - wallZs[#wallZs + 1] = zw - end - if upright then -- counted above - elseif az >= ax then - facing[nz > 0 and "south" or "north"] = - facing[nz > 0 and "south" or "north"] + 1 - -- A ROOF SLOPE IS NOT A WALL, and the counts above cannot - -- tell them apart: a rear roof pitch leaning more north than - -- up is filed as "north" here. Measured, the two do not - -- overlap at all -- every vertical wall in this cartridge - -- reads |ny| = 0.00 or 0.01, and every sloping face between - -- 0.34 and 0.69 -- so the walls are counted separately, by - -- AREA, which is the quantity the coverage test needs. - if ay < 0.2 then - local tarea = len * 0.5 - if nz > 0 then - southArea = (southArea or 0) + tarea - else - northArea = (northArea or 0) + tarea - local zn = pa[3] - if pb[3] < zn then zn = pb[3] end - if pc[3] < zn then zn = pc[3] end - northWalls = northWalls or {} - northWalls[#northWalls + 1] = zn - northWalls[#northWalls + 1] = tarea - end - end - else - facing[nx > 0 and "east" or "west"] = - facing[nx > 0 and "east" or "west"] + 1 - -- THE OUTERMOST PLANES AT WHICH THIS MODEL HAS A SIDE WALL. - -- Every east/west face, gated or not: a back wider than the - -- sides it joins shows past the corner from the FRONT, and - -- this is the only measurement that says where they are. - sideXLo = (sideXLo == nil or pa[1] < sideXLo) and pa[1] or sideXLo - sideXHi = (sideXHi == nil or pa[1] > sideXHi) and pa[1] or sideXHi - -- ...AND THE STRETCH OF z EACH ONE COVERS, because a - -- building that is wide at the front and narrow at the back - -- has no single width. Kept flat, three numbers per face. - local zs = pa[3]; local ze = pa[3] - if pb[3] < zs then zs = pb[3] end - if pc[3] < zs then zs = pc[3] end - if pb[3] > ze then ze = pb[3] end - if pc[3] > ze then ze = pc[3] end - sidePlanes = sidePlanes or {} - sidePlanes[#sidePlanes + 1] = pa[1] - sidePlanes[#sidePlanes + 1] = zs - sidePlanes[#sidePlanes + 1] = ze - -- THE COURSE THIS BUILDING'S WALLS ARE CLAD IN, as a - -- rectangle in both world and texel space. - -- - -- The number this replaces was an aggregate: the shape's - -- whole u range over its whole span. On `t1_h01` that is - -- 93.9 texels over 64 units, because `polygon0` carries the - -- porch and the chimney as well as the walls -- while a real - -- wall quad measures 28 texels over 11.75 units. An - -- aggregate of unrelated faces is not a density. - -- - -- A side wall's triangles are right halves of axis-aligned - -- quads, so ONE triangle's own z/y extent IS the quad's. - -- - -- WHICH quad the back should copy is decided by two measured - -- things, and neither is "the biggest one". - -- - -- Biggest-in-world was tried and it is wrong on every - -- building measured. `t1_h01`'s largest side face is 12.00 x - -- 32.50 units showing 5 x 6 texels -- a flat colour stretched - -- over a panel -- while the clapboard course beside it is - -- 11.75 x 28.50 showing 28 x 30. `c1_b02b`'s largest is 48 x - -- 72 units showing 0.9 x 1.6. Tiled from those the back came - -- out a flat dark grey, which is exactly what one texel - -- stretched sixty units wide looks like. - -- - -- Biggest-in-TEXELS was tried next and it is wrong too: on - -- `t1_s01` it picks a shape whose material is named `light` - -- and which wears the whole 64 x 64 atlas on a single quad -- - -- a decal, not a course -- and the back came back covered in - -- smeared windows. - -- - -- WHAT ACTUALLY SEPARATES THEM IS SCALE. Platinum's building - -- art is drawn at screen scale, so a course of siding is - -- about ONE TEXEL PER WORLD UNIT vertically. Measured across - -- nine buildings, every real wall course lands between 0.91 - -- and 1.58 (t1_h01 1.05, c1_b02b 1.13, t1_s01 1.13, c1_s02 - -- 1.09, l2_s02b 0.91, gym00 1.58, r212s02 1.21, shelf08 0.96) - -- while the stretched panels sit at 0.02 to 0.21 and the - -- squeezed decals at 2.06 to 7.53. A window of one octave - -- either side of the drawing scale keeps the courses and - -- drops both kinds of junk. - -- - -- Only the VERTICAL density is gated. Courses run - -- horizontally, so their height is what is drawn to scale; - -- their width may be squeezed because it tiles, and `t1_h01` - -- squeezes it to 2.38. - -- - -- It is a scale window and not a proof. The nearest thing it - -- excludes is that `light` face at 2.06, which is close to the - -- bound; a building whose siding is drawn at half scale would - -- be rejected by this and would need a different rule. - local zA = pa[3]; local zB = pb[3]; local zC = pc[3] - local zMin = (zA < zB and zA or zB); if zC < zMin then zMin = zC end - local zMax = (zA > zB and zA or zB); if zC > zMax then zMax = zC end - local yA = pa[2]; local yB = pb[2]; local yC = pc[2] - local yMin = (yA < yB and yA or yB); if yC < yMin then yMin = yC end - local yMax = (yA > yB and yA or yB); if yC > yMax then yMax = yC end - local uA, uB, uC = pa[4], pb[4], pc[4] - local vA, vB, vC = pa[5], pb[5], pc[5] - local uMin = (uA < uB and uA or uB); if uC < uMin then uMin = uC end - local uMax = (uA > uB and uA or uB); if uC > uMax then uMax = uC end - local vMin = (vA < vB and vA or vB); if vC < vMin then vMin = vC end - local vMax = (vA > vB and vA or vB); if vC > vMax then vMax = vC end - local dz, dy = zMax - zMin, yMax - yMin - local dens = (dy > 1e-6) and ((vMax - vMin) / dy) or 0 - -- THE BIGGEST SIDE WALL, whatever its texture does. - -- - -- Four of Sinnoh's shops -- the Veilstone restaurant, the - -- Hearthome market, `c7_s04` and `c10_s02` -- are not clad - -- in a course at all: their side walls carry a u span of - -- 0.0 texels, so the cartridge paints them from a single - -- texel and there is no scale to match. They got no back. - -- Where there is no course to copy, the back is painted from - -- that same texel, which fits in exactly because it is the - -- colour the walls already are. - if dz * dy > (flatBand and flatBand.area or 0) then - flatBand = { area = dz * dy, u = uA, v = vA, - width = dz, height = dy, yFoot = yMin } - end - if dz > 1e-6 and dy > 1e-6 - and (uMax - uMin) >= WALL_MIN_TEXELS - and (vMax - vMin) >= WALL_MIN_TEXELS - and dens >= WALL_SCALE_LO and dens <= WALL_SCALE_HI then - -- ...AND THE SAME RECT ON MORE FACES BEATS A BIGGER ONE ON - -- fewer, because a course is what a building REPEATS. The - -- world area is summed per distinct texel rect. - bands = bands or {} - local key = ("%d,%d,%d,%d"):format( - uMin * 16 + 0.5, uMax * 16 + 0.5, - vMin * 16 + 0.5, vMax * 16 + 0.5) - local b = bands[key] - if not b then - -- WHICH END OF V IS THE BOTTOM, read off the face rather - -- than assumed: Gen 4 textures run v downward, so the - -- wall's foot carries the LARGER v -- but that is a - -- convention, and the vertex says it outright. - local vFoot = vA - if yB < yA and yB <= yC then vFoot = vB - elseif yC < yA and yC < yB then vFoot = vC end - b = { area = 0, width = dz, height = dy, - uLo = uMin, uHi = uMax, vLo = vMin, vHi = vMax, - vAtFoot = vFoot, yFoot = yMin, - xLo = math.huge, xHi = -math.huge, - zLo = math.huge } - bands[key] = b - end - b.area = b.area + dz * dy - -- WHERE THE SIDE WALLS STAND. An east/west face is planar - -- in x, so the faces wearing this course mark the two - -- planes the back has to span -- and NOT one unit further, - -- which is what the model's bounding box would have given. - if pa[1] < b.xLo then b.xLo = pa[1] end - if pa[1] > b.xHi then b.xHi = pa[1] end - -- ...AND HOW FAR NORTH THEY REACH. North is -z, so the - -- smallest z on the faces wearing this course is the plane - -- the back belongs on. Taking it from the shape's bounding - -- box instead put the back out under the ROOF's north - -- overhang, where nothing covers it. - if zMin < b.zLo then b.zLo = zMin end - if b.area > (wallBand and wallBand.area or 0) then - wallBand = b - end - end end - end - end - end - if wallBand and not (wallBand.width > 1e-6 and wallBand.height > 1e-6 - and wallBand.uHi > wallBand.uLo - and wallBand.vHi > wallBand.vLo) then - wallBand = nil - end - end - self.shapes[#self.shapes + 1] = +-- Copyright (c) 2026 Cedric. All rights reserved. +-- Source-available under the Gen2Recomped License (see LICENSE.md): you may +-- read, build and privately modify this file; you may not redistribute it or +-- use it commercially. Cartridge-derived data is excluded and is not the +-- copyright holder's to license. + +-- Drawing a Gen 4 model, which is the first real 3D this engine does itself. +-- +-- The voxel mod has its own 3D and the tilt ground quad is one mesh with one +-- shader, but the engine core has never had a general "here is a mesh, draw +-- it" path -- so Platinum's 590 buildings, Giratina on the title, the field +-- effects and Professor Rowan's briefcase all had geometry sitting in the +-- cache with nothing able to put it on screen. +-- +-- THIS IS DELIBERATELY SELF-CONTAINED. It does not register a pipeline, does +-- not touch the world pass, and does not ask the Renderer for anything: a +-- screen creates one of these, draws it into its own canvas, and throws it +-- away. The reason is that the first thing to use it is the starter select -- +-- a menu with three Poke Balls in a briefcase -- which has no world behind it +-- and no business being part of the world's pipeline. When the map meshes +-- arrive they will want the pipeline; a menu does not, and building for the +-- harder case first would mean neither worked. +-- +-- WHAT DEPTH COSTS. A 3D scene needs a depth buffer, which means a canvas +-- created with one and `setDepthMode` around the draw. Both are restored +-- afterwards, unconditionally, because this runs inside somebody else's draw +-- and leaving either set turns the next 2D blit into a puzzle. + +local Assets = require("src.render.Assets") +local Logger = require("src.core.Logger") +-- The pose walk lives with the reader that produced the bytecode. Requiring +-- an importer from a renderer is not pretty; having the same walk written +-- twice and drifting apart would be worse, and this is the walk that decides +-- where every shape stands. +local Gen4Nsbmd = require("src.import.Gen4Nsbmd") + +local Gen4Model = {} +Gen4Model.__index = Gen4Model + +-- Matches Gen4ModelPack: a vertex is fourteen bytes and a coordinate is fx16. +-- 16, not 14: the last three bytes are the normal, as three signed bytes. +-- +-- Platinum's display lists issue NORMAL and no COLOR -- the DS lights in +-- hardware -- so a vertex whose normal was dropped in packing keeps the +-- decoder's white default, and every polygon draws with no directional term. +-- That is what made the whole of Sinnoh read flat under a correct camera. +local VERTEX_BYTES = 16 +-- What it was before the normal was added. Kept so a cache written by an +-- older import is READ correctly rather than read confidently and wrongly. +local LEGACY_VERTEX_BYTES = 14 +local FX16 = 4096 +local UV_UNITS = 16 + +-- The vertex shader takes a model-view-projection matrix and nothing else. +-- +-- `TransformProjectionMatrix` is LOVE's own 2D transform and is deliberately +-- ignored: the whole point here is to put clip-space coordinates out directly, +-- and mixing the two would apply the 2D camera to a 3D scene. +-- ...AND A TEXTURE TRANSFORM, which is what a Gen 4 texture animation is. +-- +-- `bm_anime`'s BTA0 animations scroll, scale and rotate a material's texture +-- coordinates -- that is how Platinum's water moves and its fountains run. +-- THE UNITS ARE NORMALISED, measured rather than assumed: every scroll in that +-- archive runs its translate from 0.0 to exactly -1.0 over its own frame count +-- (the waterfall to -2.0, which is two cycles in the same time) with scale held +-- at 1.0. A translate that lands on whole units is one full wrap of the +-- texture; texel units would have run to 16 or 64 and do not. +-- +-- This port's UVs are already divided by the texture's size when the mesh is +-- built, so the transform applies directly with nothing to convert. +-- +-- Two vectors rather than a mat3 because a 2x2 and an offset is all a texture +-- SRT is, and because every shape sends them on every draw -- including the +-- overwhelming majority that are identity. Sending them ALWAYS is deliberate: +-- a uniform that is declared and not sent reads as zero, and a zero texture +-- matrix collapses every coordinate to one texel, which is a model drawn in a +-- single flat colour. +-- `yCut` IS WHAT MAKES WALK-BEHIND POSSIBLE. +-- +-- Every fragment carries the model-space height it came from, and the pixel +-- stage drops the ones at or below the cut. Drawing a building twice -- once +-- whole, under the sprites, and once with everything below head height cut +-- away, over them -- is what lets a character pass BEHIND a house: the roof +-- and upper walls are painted after the sprite, the ground floor is not, and +-- somebody standing in front of the door is still drawn in front of it. +-- +-- The default is a number no geometry reaches, so a caller that does not ask +-- for a cut gets the whole model. It is SENT ON EVERY DRAW rather than left +-- unset, because an unset uniform reads as zero and zero would quietly cut +-- every model in the game off at the waist. +local SHADER = [[ +varying float vModelY; +varying float vModelZ; +#ifdef VERTEX +uniform mat4 mvp; +// x = how much a unit of HEIGHT spreads; y = THE HEIGHT IT SPREADS ABOUT. +// +// The datum is not 0 and assuming it was is a measured fault. The factor is +// exactly 1 at y = heightSpread.y, so that is the plane the picture pivots +// around -- and the plane it has to be is THE ONE THE SPRITES STAND ON, since +// they are placed by the flat projection and get no spread at all. Twinleaf's +// ground is at y = 16, so anchoring at 0 scaled the whole ground plane about +// the screen centre while the characters on it stayed put: 50.6% of ground +// pixels moved at the CARTRIDGE rung and 65.5% at rung 30. Reported from play +// as standing "a few blocks back" from a building one tile away, and as the +// player looking "smaller than i should be" -- the world was being scaled up +// around them and they were not. +uniform vec2 heightSpread; +// THE CARD'S OWN AXIS, and the angle every card turns about it. +// +// `BillboardPivot.xy` is the card's centre in x and z; `.z` is 1 on a card. +// `billboardYaw` is the camera's heading, and it is ZERO on every pass that is +// not a free camera -- which is what leaves the field and tilt views +// arithmetically identical, since a rotation by zero is the identity. +// +// ABOUT Y ONLY. Requested: trees *"should always face the players camera"* +// but *"also dont change when looking up or down"*. A full look-at billboard +// tips the card back as the camera rises and the trees appear to lie down; a +// turn about the vertical keeps the cartridge's own 35-degree lean exactly as +// authored and only spins it to face you. +attribute vec3 BillboardPivot; +uniform float billboardYaw; + +vec4 turnToCamera(vec4 v) +{ + if (BillboardPivot.z < 0.5) { return v; } + vec2 d = v.xz - BillboardPivot.xy; + float sa = sin(billboardYaw); + float ca = cos(billboardYaw); + // THE SIGN IS THE ENGINE'S, not a textbook's. `Gen4View:follow` puts the + // eye at `x - sin(yaw) * r, z + cos(yaw) * r` -- the x term is NEGATIVE -- + // so a rotation written the usual way turns the cards the wrong way and + // doubles the error instead of cancelling it. Measured with a camera + // built by `Gen4View` itself: the wrong sign collapsed a card from 6264 + // painted pixels to 188 at 60 degrees. + v.xz = BillboardPivot.xy + vec2(d.x * ca - d.y * sa, d.x * sa + d.y * ca); + return v; +} +vec4 position(mat4 transform_projection, vec4 vertex_position) +{ + vec4 turned = turnToCamera(vertex_position); + vModelY = turned.y; + vModelZ = turned.z; + vec4 p = mvp * turned; + // THE ONE PERSPECTIVE TERM, and the reason it is here and not in `mvp`. + // + // A tree's top is nearer the eye than its base by `h * cos(pitch)`, so the + // cartridge's camera spreads it outward from the screen centre by + // `D / (D - h*cos(pitch))`. That factor multiplies the vertex's SCREEN + // OFFSET by a function of its HEIGHT -- a product of two coordinates, + // which no 4x4 matrix can express. It has to be a per-vertex divide, so + // it lives here. + // + // Screen centre is NDC (0,0) because the matrix is built per frame around + // the camera, so scaling p.xy about the origin IS spreading from the + // centre of the view. At h = 0 the factor is exactly 1, which is what + // keeps the GROUND PLANE pixel-identical -- and therefore every sprite, + // every collision pick and every screen-row-to-tile-row answer the + // overworld already computes. + float s = 1.0 - (turned.y - heightSpread.y) * heightSpread.x; + if (s < 0.25) { s = 0.25; } + p.xy /= s; + return p; +} +#endif +#ifdef PIXEL +uniform vec4 uvRotScale; +uniform vec2 uvTranslate; +uniform float yCut; +// (lo, hi] in model z: a band of the cut pass that is left unpainted -- the +// ground just BEHIND the player, see Gen4Ground:livePass. Empty by default. +uniform vec2 zKeep; +vec4 effect(vec4 colour, Image tex, vec2 uv, vec2 screen) +{ + if (vModelY <= yCut) { discard; } + if (vModelZ > zKeep.x && vModelZ <= zKeep.y) { discard; } + vec2 t = vec2(uvRotScale.x * uv.x + uvRotScale.y * uv.y, + uvRotScale.z * uv.x + uvRotScale.w * uv.y) + uvTranslate; + vec4 texel = Texel(tex, t); + // A Gen 4 texture keeps colour 0 transparent, and a transparent texel must + // not write depth -- otherwise the hole punched through a leaf or a strap + // occludes whatever is behind it. Discarding is what makes that correct + // rather than merely usually correct. + if (texel.a < 0.5) { discard; } + return texel * colour; +} +#endif +]] + +-- THE SAME SHADER WITHOUT THE HEIGHT CUT, kept as a fallback. +-- +-- The cut needs a `varying`, and a varying is the one part of this shader that +-- a driver can reasonably refuse -- GLES wants a precision qualifier, and some +-- older GL profiles are fussy about declaring one outside the stage blocks. +-- If it will not compile, losing walk-behind is a much smaller loss than +-- losing every model in the game, which is what `ensureShader` returning nil +-- costs: `draw` bails on a nil shader and the whole world goes black. +local SHADER_NO_CUT = [[ +#ifdef VERTEX +uniform mat4 mvp; +// x = how much a unit of HEIGHT spreads; y = THE HEIGHT IT SPREADS ABOUT. +// +// The datum is not 0 and assuming it was is a measured fault. The factor is +// exactly 1 at y = heightSpread.y, so that is the plane the picture pivots +// around -- and the plane it has to be is THE ONE THE SPRITES STAND ON, since +// they are placed by the flat projection and get no spread at all. Twinleaf's +// ground is at y = 16, so anchoring at 0 scaled the whole ground plane about +// the screen centre while the characters on it stayed put: 50.6% of ground +// pixels moved at the CARTRIDGE rung and 65.5% at rung 30. Reported from play +// as standing "a few blocks back" from a building one tile away, and as the +// player looking "smaller than i should be" -- the world was being scaled up +// around them and they were not. +uniform vec2 heightSpread; +// THE CARD'S OWN AXIS, and the angle every card turns about it. +// +// `BillboardPivot.xy` is the card's centre in x and z; `.z` is 1 on a card. +// `billboardYaw` is the camera's heading, and it is ZERO on every pass that is +// not a free camera -- which is what leaves the field and tilt views +// arithmetically identical, since a rotation by zero is the identity. +// +// ABOUT Y ONLY. Requested: trees *"should always face the players camera"* +// but *"also dont change when looking up or down"*. A full look-at billboard +// tips the card back as the camera rises and the trees appear to lie down; a +// turn about the vertical keeps the cartridge's own 35-degree lean exactly as +// authored and only spins it to face you. +attribute vec3 BillboardPivot; +uniform float billboardYaw; + +vec4 turnToCamera(vec4 v) +{ + if (BillboardPivot.z < 0.5) { return v; } + vec2 d = v.xz - BillboardPivot.xy; + float sa = sin(billboardYaw); + float ca = cos(billboardYaw); + // THE SIGN IS THE ENGINE'S, not a textbook's. `Gen4View:follow` puts the + // eye at `x - sin(yaw) * r, z + cos(yaw) * r` -- the x term is NEGATIVE -- + // so a rotation written the usual way turns the cards the wrong way and + // doubles the error instead of cancelling it. Measured with a camera + // built by `Gen4View` itself: the wrong sign collapsed a card from 6264 + // painted pixels to 188 at 60 degrees. + v.xz = BillboardPivot.xy + vec2(d.x * ca - d.y * sa, d.x * sa + d.y * ca); + return v; +} +vec4 position(mat4 transform_projection, vec4 vertex_position) +{ + vec4 turned = turnToCamera(vertex_position); + vec4 p = mvp * turned; + float s = 1.0 - (turned.y - heightSpread.y) * heightSpread.x; + if (s < 0.25) { s = 0.25; } + p.xy /= s; + return p; +} +#endif +#ifdef PIXEL +uniform vec4 uvRotScale; +uniform vec2 uvTranslate; +vec4 effect(vec4 colour, Image tex, vec2 uv, vec2 screen) +{ + vec2 t = vec2(uvRotScale.x * uv.x + uvRotScale.y * uv.y, + uvRotScale.z * uv.x + uvRotScale.w * uv.y) + uvTranslate; + vec4 texel = Texel(tex, t); + if (texel.a < 0.5) { discard; } + return texel * colour; +} +#endif +]] + +local shader +local shaderCuts = false + +local function ensureShader() + if shader ~= nil then return shader or nil end + local ok, made = pcall(love.graphics.newShader, SHADER) + if ok and made then + shader, shaderCuts = made, true + return shader + end + Logger.warn("gen4 model: the mesh shader with the height cut would not " + .. "compile (%s) -- falling back to the plain one, so models " + .. "still draw but nothing will occlude a sprite", + tostring(made)) + local plainOk, plain = pcall(love.graphics.newShader, SHADER_NO_CUT) + if not plainOk or not plain then + Logger.error("gen4 model: the mesh shader would not compile (%s); " + .. "no 3D model will draw", tostring(plain)) + shader = false + return nil + end + shader, shaderCuts = plain, false + return shader +end + +-- Whether this device got the shader that can cut a model off at a height. +-- The canopy pass asks, because baking one that cannot cut would paint whole +-- buildings over the sprites instead of only their roofs. +function Gen4Model.cutsHeight() + ensureShader() + return shaderCuts +end + +-- --------------------------------------------------------------------------- +-- Matrices +-- --------------------------------------------------------------------------- + +-- Row-major 4x4s, as flat sixteen-element tables, because that is the layout +-- Shader:send takes and converting once here beats converting at every draw. +local function identity() + return { 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 } +end + +local function multiply(a, b) + local out = {} + for row = 0, 3 do + for col = 0, 3 do + local sum = 0 + for k = 0, 3 do + sum = sum + a[row * 4 + k + 1] * b[k * 4 + col + 1] + end + out[row * 4 + col + 1] = sum + end + end + return out +end + +function Gen4Model.perspective(fovY, aspect, near, far) + local f = 1 / math.tan(fovY / 2) + return { + f / aspect, 0, 0, 0, + 0, f, 0, 0, + 0, 0, (far + near) / (near - far), (2 * far * near) / (near - far), + 0, 0, -1, 0, + } +end + +-- THE OTHER PROJECTION THE CARTRIDGE USES. +-- +-- Three hundred of Platinum's 593 map headers are CAMERA_TYPE_INTERIOR_- +-- ORTHOGRAPHIC -- every ordinary room, the player's bedroom included -- and +-- `Camera_ComputeProjectionMatrix` really does build an orthographic box for +-- them. A perspective matrix at the same numbers is not an approximation of +-- that, it is a different picture: an orthographic camera has no parallax at +-- all, which is exactly what the cartridge wants indoors. +-- +-- `halfHeight` is in WORLD UNITS at the target plane, which is the form the +-- cartridge states it in (`top = tan(fovY) * distance`). +function Gen4Model.orthographic(halfHeight, aspect, near, far) + local h = (tonumber(halfHeight) or 1) + if h < 1e-6 then h = 1e-6 end + local w = h * (tonumber(aspect) or 1) + near, far = tonumber(near) or 1, tonumber(far) or 4096 + if far - near < 1e-6 then far = near + 1 end + return { + 1 / w, 0, 0, 0, + 0, 1 / h, 0, 0, + 0, 0, -2 / (far - near), -(far + near) / (far - near), + 0, 0, 0, 1, + } +end + +-- A camera that looks at a point from a distance, around it and above it. +-- Spelled out rather than composed from a `lookAt` because the only thing that +-- ever moves it is a turntable, and two angles read better than an eye vector. +function Gen4Model.orbit(target, distance, yaw, pitch) + local cy, sy = math.cos(yaw), math.sin(yaw) + local cp, sp = math.cos(pitch), math.sin(pitch) + local rotateY = { + cy, 0, -sy, 0, + 0, 1, 0, 0, + sy, 0, cy, 0, + 0, 0, 0, 1, + } + local rotateX = { + 1, 0, 0, 0, + 0, cp, sp, 0, + 0, -sp, cp, 0, + 0, 0, 0, 1, + } + local translate = identity() + translate[4] = -(target and target[1] or 0) + translate[8] = -(target and target[2] or 0) + translate[12] = -(target and target[3] or 0) + local back = identity() + back[12] = -(distance or 1) + return multiply(back, multiply(rotateX, multiply(rotateY, translate))) +end + +-- Exported beside the two camera helpers, because a caller that builds its own +-- view and projection has to combine them and re-deriving a 4x4 multiply at +-- every call site is how two of them end up disagreeing. +Gen4Model.multiply = multiply + +-- A camera placed where the cartridge puts it, looking where it looks. +-- +-- `orbit` above is the right shape for a turntable and the wrong one for a +-- scripted shot: Platinum's title camera is two POINTS that interpolate -- +-- eye (0, 192, 600) to (-64, 192, 484) over sixty frames, target fixed at +-- (0, 100, -18) -- and turning a pair of points into a distance and two +-- angles to hand to `orbit` would throw away the straight line between them. +function Gen4Model.lookAt(eye, target, up) + up = up or { 0, 1, 0 } + local fx, fy, fz = target[1] - eye[1], target[2] - eye[2], target[3] - eye[3] + local fl = math.sqrt(fx * fx + fy * fy + fz * fz) + if fl < 1e-6 then fl = 1 end + fx, fy, fz = fx / fl, fy / fl, fz / fl + local sx = fy * up[3] - fz * up[2] + local sy = fz * up[1] - fx * up[3] + local sz = fx * up[2] - fy * up[1] + local sl = math.sqrt(sx * sx + sy * sy + sz * sz) + -- Looking straight down the up axis: any side vector will do, and picking + -- one is better than dividing by zero. + if sl < 1e-6 then sx, sy, sz, sl = 1, 0, 0, 1 end + sx, sy, sz = sx / sl, sy / sl, sz / sl + local ux = sy * fz - sz * fy + local uy = sz * fx - sx * fz + local uz = sx * fy - sy * fx + return { + sx, sy, sz, -(sx * eye[1] + sy * eye[2] + sz * eye[3]), + ux, uy, uz, -(ux * eye[1] + uy * eye[2] + uz * eye[3]), + -fx, -fy, -fz, (fx * eye[1] + fy * eye[2] + fz * eye[3]), + 0, 0, 0, 1, + } +end + +-- --------------------------------------------------------------------------- +-- Loading +-- --------------------------------------------------------------------------- + +local FORMAT = { + { "VertexPosition", "float", 3 }, + { "VertexTexCoord", "float", 2 }, + -- FLOAT, NOT BYTE, AND THAT IS A BUG FIX RATHER THAN A PREFERENCE. + -- + -- LOVE hands a "byte" vertex attribute to the shader UNNORMALISED -- the + -- fragment stage sees 0..255, not 0..1 -- so `texel * colour` came out at + -- 255x the texel and clamped to white for EVERY value. Measured at the + -- smallest scale that can show it: a 1x1 white texture on a three-vertex + -- mesh renders pixel-identical at vertex colour 255 and at 128 under "byte", + -- and 1.000 against 0.502 under "float", on both the custom shader and + -- LOVE's default one. + -- + -- So this format could never have shown a vertex colour at all. It did not + -- matter while every vertex in the cache was white; it is the second half of + -- why lighting the world changed nothing, and it would have quietly eaten + -- the first half. + { "VertexColor", "float", 4 }, + -- WHERE THIS VERTEX'S CARD TURNS, and whether it turns at all. + -- + -- x and y are the card's centre in the chunk's own x and z; z is 1 on a + -- camera-facing card and 0 on everything else. It is PER VERTEX rather than + -- per shape because a terrain shape is every tree on the chunk at once -- + -- `tree2_01` reaches 240 separate quads in one shape -- and they each have + -- to turn about themselves, not about the shape's middle. + -- + -- Local to this file, so Gen 1/2/3 are untouched: they have their own model + -- modules and never see this format. + { "BillboardPivot", "float", 3 }, +} + +-- A vertex that is not part of a card. Shared rather than built per vertex, +-- because most of Sinnoh is not a tree. +local NO_PIVOT = { 0, 0, 0 } + +-- WHICH FACES THE CARTRIDGE AUTHORED TO FACE THE CAMERA. +-- +-- Platinum builds a tree as a flat quad leaning back toward its fixed camera, +-- and that lean is a single exact normal: (0, 0.819, 0.575). Measured over +-- the whole cartridge it appears on `tree01`, `tree2_01`, `tree04_2`, +-- `conttree_b/t`, `conttree2_b/t`, `tree3_02`, `bf_tree03` and nothing else -- +-- 60,273 triangles across 45 materials. Walls are (1,0,0) or (0,0,1), the +-- ground is (0,1,0), and `searock` and `imped` lean at a DIFFERENT 45 degrees, +-- so none of them are caught. +-- +-- Two simpler rules were tried and thrown away. "Taller than it is flat" +-- catches every dungeon wall and step. "Made only of 4-vertex quads" is +-- better -- a tree shape really is 56 to 240 loose quads while a wall is 6- +-- and 8-vertex boxes -- but the FLAT quads are the sea, the lakes and the +-- grass floor, and standing those on end would take the world apart. +-- +-- The normal is computed from the geometry rather than read from the vertex, +-- because a cache imported before normals were carried is 14 bytes a vertex +-- and has none. The lighting pass measured the same normal independently off +-- a 16-byte cache, which is a second source for it. +local TREE_NY, TREE_NZ, TREE_TOL = 0.819, 0.575, 0.04 + +-- billboardPivots(shape, positions, count) -> per-vertex pivot, card count +-- +-- Each card gets its OWN centre, found by walking the shape's triangles as a +-- graph: a card is one connected component, and every one of them is exactly +-- four vertices. +-- HOW MANY TIMES A CARD REPEATS ITS TEXTURE ACROSS ITS WIDTH, above which it +-- is a CONTINUOUS STRIP and not a tree. +-- +-- Reported from play, twice: *"the border trees ... rotating as a large group +-- of trees instead of individually above their trunks"*, and again after the +-- pivot was moved to the base -- *"many groups of trees canopys are still +-- pivoting as if theyre rows rather than from the trunk of every tree"*. +-- +-- The second report is not a pivot fault. Measured: 100% of card components +-- are exactly one quad, so nothing is being merged. What IS happening is that +-- Platinum draws a forest border as ONE CARD -- `conttree` is *continuous* +-- tree -- 256 world units wide and 20 tall, repeating a 64-pixel texture four +-- times. All 2,956 `conttree_t` cards and all 3,738 `conttree2_t` cards are +-- wider than 48 units; the widest are 256. A quad cannot bend, so there is no +-- pivot that turns those four trees individually: the geometry for them does +-- not exist. +-- +-- SUBDIVIDING THEM WAS TRIED AND ABANDONED. Splitting a strip at its texture +-- tiles needs the repeat count to be a whole number, and across the cartridge +-- **23,171 of 30,147 cards have a non-integer u-span over texture width** -- +-- the UVs address sub-rectangles, not whole tiles. There is no derivable place +-- to cut, and cutting at a guessed one would break a strip into panels that +-- swing apart at the seams. +-- +-- So a strip is left exactly as the cartridge drew it, which is what it was +-- authored for, and only cards that really are one tree turn to face you. +-- 1.5 rather than 2 so a card that repeats "about twice" is treated as the +-- strip it is. +-- 1.25, NOT 1.5, AND THE DIFFERENCE IS THE WHOLE OF REPORT #188. +-- +-- Reported from play a third time: *"some of the trees are pivoting together +-- for example 3 trees are pivoting from one point"*. +-- +-- Measured over all 666 chunks of the cartridge, u-span divided by texture +-- width lands on clean values -- and 2,396 components sit at EXACTLY 1.50: +-- +-- 0.66 x6328 0.53 x6279 0.34 x6272 <- single trees +-- 4.00 x3534 (excluded) 2.50 x1187 (excluded) 2.00 x1159 (excluded) +-- 1.50 x2396 <- ON the old threshold, and the test is `>`, so KEPT +-- 1.00 x2250 <- a single tree, correctly kept +-- +-- A strict `>` against 1.5 lets every one of those 2,396 through, and at a +-- 64-wide texture that component is 96 world units across: THREE 32-unit +-- trees turning about one pivot, which is the report word for word. +-- +-- 1.25 rather than 1.4 or 1.49 because it is the MIDDLE OF A MEASURED GAP: +-- 21,758 components are at or under 1.00 repeats, 2,396 are at 1.50, and +-- only EIGHTEEN of 30,147 lie strictly between the two. A threshold in an +-- empty gap cannot be knocked over by float wobble in the s16 divide, which +-- is exactly what setting it on 1.5 and testing with `>=` would risk. +local STRIP_REPEATS = 1.25 + +-- ...AND A `conttree` IS NEVER ONE TREE, WHICH THE CARTRIDGE SAYS IN THE NAME. +-- +-- `conttree` is CONTINUOUS tree. The u-span test is a proxy -- it reads the +-- texture's repeat, not the quad's size -- and it misses the strips whose UVs +-- are stretched rather than tiled. The name does not miss them, and the +-- separation is total: +-- +-- conttree* components n=10,217 min width 64.0 median 128 max 256 +-- AT OR UNDER 40 UNITS: ZERO +-- every other component n=19,930 median 33 98.3% at or under 40 +-- +-- The narrowest `conttree` in the game is 64 units, which is already two +-- trees; an ordinary tree card is 33. There is no overlap to argue about. +-- +-- Effect, measured: billboarded cards wider than 48 units fall from 4,493 +-- (18.6% of all cards) to 51 (0.3%), and the widest from 96.0 to 85.3. The +-- name rule alone does most of it -- 18.6% to 0.4% -- and the threshold trims +-- the rest. +local function isContinuousStrip(shape) + local name = shape and shape.texture + return type(name) == "string" and name:find("conttree", 1, true) ~= nil +end + +local function billboardPivots(shape, positions, count, textureWidth) + local parent = {} + for i = 1, count do parent[i] = i end + local function find(a) + while parent[a] ~= a do parent[a] = parent[parent[a]]; a = parent[a] end + return a + end + local function union(a, b) + a, b = find(a), find(b) + if a ~= b then parent[a] = b end + end + + local card = {} + local indices = shape.indices or "" + for t = 0, (shape.triangleCount or 0) - 1 do + local at = t * 6 + local a1, a2, b1, b2, c1, c2 = indices:byte(at + 1, at + 6) + if not c2 then break end + local a = a1 + a2 * 256 + 1 + local b = b1 + b2 * 256 + 1 + local cc = c1 + c2 * 256 + 1 + local pa, pb, pc = positions[a], positions[b], positions[cc] + if pa and pb and pc then + union(a, b) + union(b, cc) + local ux, uy, uz = pb[1] - pa[1], pb[2] - pa[2], pb[3] - pa[3] + local vx, vy, vz = pc[1] - pa[1], pc[2] - pa[2], pc[3] - pa[3] + local nx = uy * vz - uz * vy + local ny = uz * vx - ux * vz + local nz = ux * vy - uy * vx + local len = math.sqrt(nx * nx + ny * ny + nz * nz) + if len > 1e-6 then + nx, ny, nz = nx / len, ny / len, nz / len + -- The winding decides the sign and both windings occur, so the test is + -- made on the upward-pointing form of the normal. + if ny < 0 then nx, ny, nz = -nx, -ny, -nz end + if math.abs(nx) < TREE_TOL + and math.abs(ny - TREE_NY) < TREE_TOL + and math.abs(math.abs(nz) - TREE_NZ) < TREE_TOL then + card[a], card[b], card[cc] = true, true, true + end + end + end + end + + -- A CARD TURNS ABOUT WHERE IT STANDS, not about its middle. + -- + -- Reported from play, with a screenshot: *"the border trees to the town seem + -- to be rotating billboards weird as a large group of trees instead of + -- individually above their trunks"*. + -- + -- The first version pivoted on the component's CENTROID, and a leaning card's + -- centroid is not above its base -- the cartridge tilts them 35 degrees back, + -- so the middle of the quad hangs behind the trunk. Measured on Twinleaf's + -- own border: every `conttree_b` card's centroid is 13.9 units behind its + -- base and every `conttree_t` card's is 9.0 to 12.3. Turning each about that + -- point swings it through an arc it never should have left, which reads as + -- the whole tree line sweeping sideways -- and because the trunk's offset + -- (13.9) and the canopy's (12.3) DIFFER, the two halves of one tree come + -- apart as they turn. + -- + -- So the pivot is the middle of the card's LOWEST edge: the line where it + -- meets the ground, which is the trunk. A trunk and the canopy above it + -- share that axis even though they are separate components, so they stay + -- together. + -- A CONTINUOUS STRIP IS NOT A TREE -- see `STRIP_REPEATS` above. + -- + -- Measured per component rather than per shape, because `conttree_b` mixes + -- single cards with 256-wide strips in the same shape. + local tw = tonumber(textureWidth) + if tw and tw > 0 then + local ulo, uhi = {}, {} + for i = 1, count do + if card[i] then + local r = find(i) + local u = positions[i][4] + if u then + if ulo[r] == nil or u < ulo[r] then ulo[r] = u end + if uhi[r] == nil or u > uhi[r] then uhi[r] = u end + end + end + end + local strip = {} + for r, lo in pairs(ulo) do + if (uhi[r] - lo) > STRIP_REPEATS * tw then strip[r] = true end + end + if next(strip) then + for i = 1, count do + if card[i] and strip[find(i)] then card[i] = nil end + end + end + end + + -- OUTSIDE the `tw` block on purpose: the name is readable whether or not the + -- texture loaded, and a cache with no textures should still not billboard a + -- forest border as one card. + if isContinuousStrip(shape) then + for i = 1, count do card[i] = nil end + end + + local lowest = {} + for i = 1, count do + if card[i] then + local r = find(i) + local y = positions[i][2] + if lowest[r] == nil or y < lowest[r] then lowest[r] = y end + end + end + -- Half a unit: a card's base edge is flat, and the packed coordinates + -- quantise to about a sixtieth of a unit, so this catches the base and + -- nothing above it. + local BASE_BAND = 0.5 + local sumX, sumZ, n = {}, {}, {} + for i = 1, count do + if card[i] then + local r = find(i) + if positions[i][2] - lowest[r] <= BASE_BAND then + sumX[r] = (sumX[r] or 0) + positions[i][1] + sumZ[r] = (sumZ[r] or 0) + positions[i][3] + n[r] = (n[r] or 0) + 1 + end + end + end + -- A card lying exactly flat has no "lowest edge" to speak of and every + -- vertex lands in the band, which gives the centroid -- the right answer + -- for something with no height to lean. + local cards = 0 + for _ in pairs(n) do cards = cards + 1 end + + local out = {} + for i = 1, count do + if card[i] then + local r = find(i) + out[i] = { sumX[r] / n[r], sumZ[r] / n[r], 1 } + else + out[i] = NO_PIVOT + end + end + return out, cards +end + +local function s16(data, at) + local a, b = data:byte(at + 1, at + 2) + if not b then return 0 end + local value = a + b * 256 + if value >= 32768 then value = value - 65536 end + return value +end + +-- One axis of a normal, back out of a signed byte. Nil past the end of the +-- string, which is how a shorter vertex from an older cache announces itself +-- rather than silently reading into its neighbour. +local function s8(data, at) + local byte = data:byte(at + 1) + if not byte then return nil end + if byte >= 128 then byte = byte - 256 end + return byte / 127 +end + +-- new(record) -> model, or nil when the record carries nothing drawable. +-- +-- `record` is one entry of the cache's `gen4_models`: a name, a position scale +-- and a list of shapes, each with its packed vertices, its packed indices and +-- the texture its material wears. +-- HOW FAR A GENERATED SHADOW SITS ABOVE THE FLOOR, in world units. +-- +-- Small enough to read as contact and large enough to beat the depth buffer's +-- resolution at this camera's distances; a shadow written at exactly the floor +-- height fights the floor and stipples. +local SHADOW_LIFT = 0.25 + +-- The cartridge's own shadow translucency, 9 of 31, stated here because +-- `SHADOW_ALPHA` is declared far below this and Lua scope runs downward. +local SHADOW_ALPHA_FRACTION = 9 / 31 + +-- HOW FAR FROM THE CARTRIDGE'S OWN DRAWING SCALE A WALL COURSE MAY SIT. +-- One octave either side of one texel per world unit; the measurements behind +-- it are in `addBackWall`'s caller, where the bands are chosen. +local WALL_SCALE_LO, WALL_SCALE_HI = 0.5, 2.0 +-- ...AND HOW LITTLE OF THE TEXTURE A FACE MAY SHOW AND STILL BE A COURSE. +-- A rect a couple of texels across is a colour, not a picture, however +-- plausible its vertical scale: `c1_s02`'s worst offender spreads TWO texels +-- of u over 65.81 units. There is a clean gap in the measurements -- the +-- flat-colour faces span 0.9 to 2.0 texels and the real courses 10 to 64 -- +-- so this sits in the gap rather than on a slope. +local WALL_MIN_TEXELS = 4 + +-- --------------------------------------------------------------------------- +-- THE GRASS A WILD POKEMON LIVES IN, STOOD UP +-- --------------------------------------------------------------------------- +-- +-- Requested: *"make the grass billboards that stand up and always face the +-- camera"*, and then, when the first attempt stood up the wrong layer, +-- *"looks like your making the wrong grass a billboard its supposed to be the +-- grass wild pokemon are in"*. +-- +-- WHICH LAYER, and why a name is the right way to ask. Sinnoh's ground is +-- drawn in layers, each its own polygon with its own material, and the +-- cartridge separates the lawn from the encounter grass exactly that way: +-- `ngrass` against `nectgr`. The renderer cannot ask the other question -- +-- "is this tile TALL_GRASS" -- because the behaviour byte is a movement-layer +-- fact that the terrain cache does not carry; it holds geometry and height and +-- nothing else. So the material is the question that can be asked here, and +-- it is the cartridge's own answer rather than a guess about one. +-- +-- MEASURED, ON THE ART, why the lawn must not be in this list. Every ground +-- tile in the set is 100% opaque -- there is no cut-out anywhere -- so a card +-- made from one is a solid rectangle, and what decides whether that reads as +-- grass is how much of the tile is decoration rather than flat field colour. +-- `ngrass` is 57.4% one flat green over four colours; `nectgr` is 21.1% flat +-- over nine, the rest leaf. Stood up, the first is a green slab that blanks +-- out the path behind it and the second is a clump of leaves. +-- +-- The list is names because only names are available, and it is one table so +-- that importing another region's texture set is a one-line change. Only the +-- materials that have actually been LOOKED at are in it. +-- +-- THE OTHER GRASS-ISH NAMES, now that they ARE extracted -- the note that used +-- to sit here said they were not, and that stopped being true some imports ago. +-- Census over all 666 land chunks, then each texture opened and viewed: +-- +-- `ngrass` (224 shapes) -- OUT. The lawn: 4 colours, 86.7% one flat green, +-- border 100%. Stood up it is a slab that blanks the path behind it. +-- `bf_ngrass` (9) -- OUT, and it is the same thing under another area's +-- prefix: 8 colours, 66.4% flat, border 90.6%, and it looks like a lawn. +-- `l_grass_u` / `_m` / `_d` (16 between them) -- OUT, and this one is worth +-- the sentence: stacked u-m-d they form ONE CONTINUOUS COLUMN of grass +-- blades three tiles tall, with the ground band at the foot of `_d`. That +-- is grass the cartridge ALREADY DRAWS STANDING, on a vertical face. +-- Standing it again would double it, and there is nothing to cut out -- +-- the blades run edge to edge, which is why their border agreement is +-- 12-23% against `nectgr`'s 85.9%. +-- `s_grass` (20) -- IN. A dark spiky clump on red soil: 10 colours, 41.4% +-- flat, border 67.2%. Structurally the same picture as `nectgr` -- a plant +-- with a uniform field colour around it -- which is exactly what the +-- cut-out below needs and what makes a card read as a clump. +-- +-- `nectgr` (97) -- IN, the original: 8 colours, 32.8% flat, border 85.9%. +-- +-- The `grow_*` singles and `gym04_daigrass02` are one shape each and have not +-- been looked at; one shape is not worth a card until someone has. +local GRASS_MATERIALS = { nectgr = true, s_grass = true } + +-- THE TILE'S BACKGROUND, CUT OUT. +-- +-- A ground tile is opaque all the way across -- the whole set is, there is no +-- alpha anywhere in it -- so a card made from one is a rectangle, and stood up +-- the plant reads as a clump inside a visible pale box. +-- +-- What that box is, is the GROUND: a tile has to meet the ground seamlessly on +-- all four sides, so the colour running round its border is the field it sits +-- in. Measured on `nectgr`, 63.3% of the border is (82,255,148) and that is +-- also the tile's commonest colour overall at 21.1% -- the border and the whole +-- agree, which is what says it is a background and not a pattern. On a +-- VERTICAL card that colour is behind the plant rather than under it, so it is +-- made transparent and the model shader's own alpha cut removes it. +-- +-- The flat quad keeps the untouched texture. Only the standing card wears +-- this one, and it is derived from the cartridge's own pixels rather than +-- drawn. +local grassCutouts = {} +local function grassCutout(path) + if not path then return nil end + local hit = grassCutouts[path] + if hit ~= nil then return hit or nil end + local okData, data = pcall(function() + return love.image.newImageData(Assets.resolve(path)) + end) + if not (okData and data) then grassCutouts[path] = false return nil end + local w, h = data:getDimensions() + if w < 3 or h < 3 then grassCutouts[path] = false return nil end + local counts, best, bestN = {}, nil, 0 + for y = 0, h - 1 do + for x = 0, w - 1 do + if x == 0 or y == 0 or x == w - 1 or y == h - 1 then + local r, g, b = data:getPixel(x, y) + local key = ("%d,%d,%d"):format(r * 255, g * 255, b * 255) + local n = (counts[key] or 0) + 1 + counts[key] = n + if n > bestN then bestN, best = n, key end + end + end + end + -- A border with no colour of its own is not a background. + if not best or bestN < (2 * (w + h) - 4) * 0.5 then + grassCutouts[path] = false + return nil + end + local br, bg, bb = best:match("^(%d+),(%d+),(%d+)$") + br, bg, bb = tonumber(br), tonumber(bg), tonumber(bb) + data:mapPixel(function(_, _, r, g, b, a) + if math.floor(r * 255 + 0.5) == br and math.floor(g * 255 + 0.5) == bg + and math.floor(b * 255 + 0.5) == bb then + return r, g, b, 0 + end + return r, g, b, a + end) + local okImg, img = pcall(love.graphics.newImage, data) + if not (okImg and img) then grassCutouts[path] = false return nil end + img:setFilter("nearest", "nearest") + img:setWrap("repeat", "repeat") + grassCutouts[path] = img + return img +end + +-- One standing card per TILE, not per triangle: a chunk's grass is merged into +-- quads that span two tiles as often as one, so the triangles are grouped back +-- onto the tile grid the cartridge measured (`tileUnits`, 16). +-- +-- The card is as tall as it is wide, because the texture is square and drawing +-- it at any other aspect stretches art that was painted at 16x16. It stands +-- on the LOWEST corner of its tile so it plants in sloping ground rather than +-- floating over it, and it wears the tile's own UV rectangle, so a chunk whose +-- grass sits in a corner of the atlas gets that corner and not the whole sheet. +local function addGrassCards(self, shape, vertices, positions, tileUnits) + local image = grassCutout(shape.image) + if not image then return false end + local indices = shape.indices or "" + local half = tileUnits * 0.5 + local tiles, order = {}, {} + + -- ONE CARD PER TILE THE SHAPE ACTUALLY COVERS, and the previous version could + -- not do that however it was keyed. + -- + -- It grouped TRIANGLES by the tile their centre fell in and then took that + -- group's min/max UV as the card's rectangle. Measured over all 666 chunks, + -- **2,812 of nectgr's 3,440 triangles (81.7%) span more than one tile**, and + -- the commonest case is exactly 2x2 (924 of them, then 2x1 and 1x2 at 288 and + -- 284, and 4x4 at 182). A quad covering 2x2 tiles has a UV range two texture + -- widths across in each axis, so the card wore the texture FOUR TIMES in one + -- square -- reported as *"some are rendered with 2x2 grass textures in a + -- square for 1 tile"*. The same group's min y is the low corner of the whole + -- patch rather than of one tile, so on sloping ground the card started below + -- the ground: *"some are but are too tall instead of starting at the ground"*. + -- One fault, two symptoms. + -- + -- Keying cannot fix it, because no key splits a triangle that covers several + -- tiles. So the walk is the other way round: for every tile a triangle + -- touches, ask the TRIANGLE what the ground and the texture do at that tile's + -- centre. A tile is claimed by the first triangle that actually contains its + -- centre, so a tile is never built twice and never averaged across a seam. + for t = 0, (shape.triangleCount or 0) - 1 do + local at = t * 6 + local a1, a2, b1, b2, c1, c2 = indices:byte(at + 1, at + 6) + if not c2 then break end + local ia = a1 + a2 * 256 + 1 + local ib = b1 + b2 * 256 + 1 + local ic = c1 + c2 * 256 + 1 + local pa, pb, pc = positions[ia], positions[ib], positions[ic] + local va, vb, vc = vertices[ia], vertices[ib], vertices[ic] + if pa and pb and pc and va and vb and vc then + -- THE PLANE OF THE TRIANGLE IN THE GROUND AXES. A grass quad is flat and + -- its mapping is linear, so y, u and v are each an affine function of + -- (x, z) and one barycentric solve serves all three. + local det = (pb[3] - pc[3]) * (pa[1] - pc[1]) + + (pc[1] - pb[1]) * (pa[3] - pc[3]) + if math.abs(det) > 1e-6 then + local xlo = math.min(pa[1], pb[1], pc[1]) + local xhi = math.max(pa[1], pb[1], pc[1]) + local zlo = math.min(pa[3], pb[3], pc[3]) + local zhi = math.max(pa[3], pb[3], pc[3]) + -- The nudge keeps a quad whose edge sits exactly on a tile boundary + -- from claiming the tile beyond it -- the corner-vs-centre problem the + -- old comment describes, arriving from the other side. + local tx0 = math.floor(xlo / tileUnits + 1e-4) + local tx1 = math.floor((xhi - 1e-4) / tileUnits) + local tz0 = math.floor(zlo / tileUnits + 1e-4) + local tz1 = math.floor((zhi - 1e-4) / tileUnits) + for tz = tz0, tz1 do + for tx = tx0, tx1 do + local key = ("%d,%d"):format(tx, tz) + if not tiles[key] then + local cx = (tx + 0.5) * tileUnits + local cz = (tz + 0.5) * tileUnits + local w1 = ((pb[3] - pc[3]) * (cx - pc[1]) + + (pc[1] - pb[1]) * (cz - pc[3])) / det + local w2 = ((pc[3] - pa[3]) * (cx - pc[1]) + + (pa[1] - pc[1]) * (cz - pc[3])) / det + local w3 = 1 - w1 - w2 + -- INSIDE THIS TRIANGLE, with a hair of slack so a centre landing + -- exactly on the shared edge of two triangles is not dropped by + -- both of them. + if w1 >= -1e-4 and w2 >= -1e-4 and w3 >= -1e-4 then + local function lerp(fa, fb, fc) return w1 * fa + w2 * fb + w3 * fc end + -- HOW FAST THE TEXTURE RUNS OVER THE GROUND, solved on this + -- triangle rather than assumed to be one tile per repeat: a + -- chunk whose grass sits in a corner of the atlas has its own + -- rate, and that is what decides the card's rectangle. + local iu = ((pb[3] - pc[3]) * (va[4] - vc[4]) + + (pc[3] - pa[3]) * (vb[4] - vc[4])) / det + local iv = ((pa[1] - pc[1]) * (vb[5] - vc[5]) + + (pc[1] - pb[1]) * (va[5] - vc[5])) / det + local u = lerp(va[4], vb[4], vc[4]) + local v = lerp(va[5], vb[5], vc[5]) + local tile = { + cx = cx, cz = cz, + -- THE GROUND AT THIS TILE'S OWN CENTRE, which is the whole of + -- the "too tall" fix: a group minimum over a 4x4 patch of + -- slope is metres below the tile the card stands on. + y = lerp(pa[2], pb[2], pc[2]), + -- EXACTLY ONE TILE OF TEXTURE, in each axis, whichever way + -- the mapping runs. The old rectangle was the whole merged + -- quad's and repeated once per tile it spanned. + uLo = u - iu * half, uHi = u + iu * half, + vLo = v - iv * half, vHi = v + iv * half, + } + tiles[key] = tile + order[#order + 1] = tile + end + end + end + end + end + end + end + + local verts, map = {}, {} + local lo = { math.huge, math.huge, math.huge } + local hi = { -math.huge, -math.huge, -math.huge } + for _, tile in ipairs(order) do + local cx, cz = tile.cx, tile.cz + local y0, y1 = tile.y, tile.y + tileUnits + local base = #verts + -- The pivot is the card's FOOT, so it turns about where it is planted -- + -- the same rule the trees needed, and for the same reason. + verts[#verts + 1] = + { cx - half, y0, cz, tile.uLo, tile.vHi, 1, 1, 1, 1, cx, cz, 1 } + verts[#verts + 1] = + { cx + half, y0, cz, tile.uHi, tile.vHi, 1, 1, 1, 1, cx, cz, 1 } + verts[#verts + 1] = + { cx + half, y1, cz, tile.uHi, tile.vLo, 1, 1, 1, 1, cx, cz, 1 } + verts[#verts + 1] = + { cx - half, y1, cz, tile.uLo, tile.vLo, 1, 1, 1, 1, cx, cz, 1 } + map[#map + 1] = base + 1; map[#map + 1] = base + 2; map[#map + 1] = base + 3 + map[#map + 1] = base + 1; map[#map + 1] = base + 3; map[#map + 1] = base + 4 + if cx - half < lo[1] then lo[1] = cx - half end + if cx + half > hi[1] then hi[1] = cx + half end + if y0 < lo[2] then lo[2] = y0 end + if y1 > hi[2] then hi[2] = y1 end + if cz < lo[3] then lo[3] = cz end + if cz > hi[3] then hi[3] = cz end + end + if #verts == 0 then return false end + + local okMesh, mesh = + pcall(love.graphics.newMesh, FORMAT, verts, "triangles", "static") + if not (okMesh and mesh) then return false end + pcall(mesh.setVertexMap, mesh, map) + mesh:setTexture(image) + self.shapes[#self.shapes + 1] = { + mesh = mesh, name = (shape.name or "grass") .. "Cards", index = nil, + material = shape.texture, image = image, + lo = lo, hi = hi, + } + return true +end + +-- --------------------------------------------------------------------------- +-- SPLITTING A WALL COURSE INTO ITS SIDING AND ITS GLASS +-- --------------------------------------------------------------------------- +-- +-- Reported from play: *"the houses have repeating window textures maybe just +-- do one window and fill the rest with the wood texture"*. +-- +-- Correct, and the cause is that not every band is a course. `t1_s01`'s wall +-- rect is 36 x 30 texels of siding-window-siding, and the cartridge puts it on +-- ONE quad per side wall -- a panel with a window in it, never repeated. +-- `t1_h01`'s rect really is a course and really does tile, twice per side wall +-- and mirrored. Tiling both gives one right wall and one wall of windows. +-- +-- WHAT SEPARATES THEM IS PURE WHITE. Read off the two textures: `t1_h01`'s +-- band runs from 0.542 to 0.821 luminance and never touches white, while +-- `t1_s01`'s glass is exactly 1.000 against siding at 0.756 to 0.871. Glass +-- is the only thing in a Platinum house wall drawn at the palette's white, and +-- a band with none in it is siding all the way across. +-- +-- The FRAME around the glass is found without a second number: among the +-- columns that carry no white, the profile that occurs most often is the +-- siding (on `t1_s01`, ten columns of thirty-six), and the window block is +-- grown outward from the glass for as long as the next column is not that +-- profile. That takes in the grey surround and the two courses beside it and +-- stops at the first real siding column -- exactly, with nothing tuned. +local bandSplitCache = {} +local function splitWallBand(path, band, tw, th) + if not (path and band and tw and th) then return nil end + local key = ("%s|%d|%d|%d|%d"):format(path, + band.uLo * 16, band.uHi * 16, band.vLo * 16, band.vHi * 16) + local hit = bandSplitCache[key] + if hit ~= nil then return hit or nil end + + -- `Assets.image` hands back a placeholder for a path that is not there -- + -- that is what buried the indoor shadows for a whole pass -- so the pixels + -- are read with the call that RAISES instead. + local okData, data = pcall(function() + return love.image.newImageData(Assets.resolve(path)) + end) + if not (okData and data) then bandSplitCache[key] = false return nil end + + local u0, v0 = math.floor(band.uLo), math.floor(band.vLo) + local w = math.floor(band.uHi - band.uLo + 0.5) + local h = math.floor(band.vHi - band.vLo + 0.5) + if w < 2 or h < 1 then bandSplitCache[key] = false return nil end + + local profile, white = {}, {} + for i = 0, w - 1 do + local parts, hasWhite = {}, false + for j = 0, h - 1 do + local okPx, r, g, b = pcall(data.getPixel, data, + (u0 + i) % tw, (v0 + j) % th) + if not okPx then bandSplitCache[key] = false return nil end + if r >= 0.999 and g >= 0.999 and b >= 0.999 then hasWhite = true end + parts[#parts + 1] = ("%d,%d,%d"):format(r * 255, g * 255, b * 255) + end + profile[i] = table.concat(parts, ";") + white[i] = hasWhite + end + + local first, last + for i = 0, w - 1 do + if white[i] then + if first == nil then first = i end + last = i + end + end + -- No glass: the whole band is siding and tiles as it always did. + if first == nil then bandSplitCache[key] = false return nil end + + local counts, sidingProfile, sidingCount = {}, nil, 0 + for i = 0, w - 1 do + if not white[i] then + local n = (counts[profile[i]] or 0) + 1 + counts[profile[i]] = n + if n > sidingCount then sidingCount, sidingProfile = n, profile[i] end + end + end + if not sidingProfile then bandSplitCache[key] = false return nil end + + while first > 0 and profile[first - 1] ~= sidingProfile do first = first - 1 end + while last < w - 1 and profile[last + 1] ~= sidingProfile do last = last + 1 end + + -- The widest unbroken run of siding left over is what the wall is filled + -- with. Courses are horizontal, so any slice of them tiles invisibly. + local bestLo, bestHi, runLo = nil, nil, nil + for i = 0, w do + local siding = (i < w) and (profile[i] == sidingProfile) or false + if siding and runLo == nil then runLo = i end + if not siding and runLo ~= nil then + if bestLo == nil or (i - runLo) > (bestHi - bestLo + 1) then + bestLo, bestHi = runLo, i - 1 + end + runLo = nil + end + end + if bestLo == nil then bandSplitCache[key] = false return nil end + + local out = { + fillLo = band.uLo + bestLo, fillHi = band.uLo + bestHi + 1, + featLo = band.uLo + first, featHi = band.uLo + last + 1, + } + bandSplitCache[key] = out + return out +end + +-- CLOSE A BUILDING'S MISSING BACK. +-- +-- Requested: *"the backs of buildings are blank which is normal for the rom but +-- we should find a way to fill the back with a texture that fits in"*, and +-- later *"they should use textures from the building they are a part of so they +-- look natural"*. +-- +-- The cartridge really does omit it -- the camera only ever looks north, so the +-- north face is never seen and was never modelled. Measured on Twinleaf's own +-- house `t1_h01`: 158 triangles, **0 facing north**, against 50 south, 43 east, +-- 41 west and 24 up. `c1_b02b` has 2 north against 96 south. +-- +-- EDGE-LOOP CAPPING WAS THE PLAN AND IT DOES NOT WORK HERE. Capping needs a +-- boundary loop, and this geometry has no boundary: `t1_h01` has **273 open +-- edges of 360** because the walls are separate quads that share no vertices. +-- There is no loop to walk. +-- +-- So the back is built from the building's own WALLS instead: their outline +-- gives its shape, their outermost planes give its width and the plane it +-- stands on, and one of their faces gives the course it is tiled with. Four +-- separate measurements, each with its own note below, and the whole point of +-- taking them from the walls is that a back built this way has no numbers of +-- its own to be wrong. +-- +-- Right for a boxy house, which is what Sinnoh's are; an L-shaped building +-- gets a flat back across its extent rather than a fitted one, and that is the +-- honest limit of a single plane. +-- +-- WHAT THIS COSTS, measured: with the back on, Twinleaf drawn at the cartridge +-- rung is pixel-identical to Twinleaf without it -- 0 differing pixels of +-- 307,200 -- and the third-person view from the FRONT differs by 21, while the +-- view from BEHIND differs by 15.7 per cent. A control that changes nothing +-- where nothing should change and everything where it should. +local function addBackWall(self) + local north, south = 0, 0 + local widest, widestArea = nil, -1 + for _, shape in ipairs(self.shapes) do + north = north + (shape.facing and shape.facing.north or 0) + south = south + (shape.facing and shape.facing.south or 0) + -- THE SHAPE WHOSE WALL COURSE COVERS THE MOST OF THE BUILDING, not the + -- one with the most side-facing triangles. A count is not an area: a + -- flight of steps can carry more east/west triangles than the wall behind + -- it. + local area = shape.wallBand and shape.wallBand.area or -1 + if area > widestArea then widestArea, widest = area, shape end + end + -- ...AND IF NO SHAPE HAS A COURSE, THE BIGGEST PLAIN WALL INSTEAD. + local flat + if not (widest and widest.wallBand) then + local best = -1 + for _, shape in ipairs(self.shapes) do + local fb = shape.flatBand + if fb and shape.image and fb.area > best then + best, widest, flat = fb.area, shape, fb + end + end + end + -- A model with no south face at all is not a building with a missing back. + -- Whether it ALREADY has a back is decided further down, against the + -- opening this would actually fill, because a ratio of triangle counts got + -- that wrong for the two buildings it matters most for. + if south == 0 then return false end + local _ = north + if not (widest and widest.image and (widest.wallBand or flat)) then + return false + end + -- A flat wall is a band whose rectangle is a point: one cell wide, one row + -- tall, the same texel everywhere. Everything below then works unchanged -- + -- the triangle wave has no span to sweep, the sawtooth no height to climb, + -- and `splitWallBand` declines a rect it cannot read a column from. + local band = widest.wallBand + -- THE TEXTURE'S OWN SIZE, because the mesh format wants 0..1 and the band + -- was measured in texels. + -- + -- This is the fault the first version shipped with, and it was visible from + -- ten feet away: a texel count was written straight into a 0..1 coordinate, + -- so a 64-pixel texture repeated sixty-four times faster than it should and + -- every back wall came out as noise. Reported as *"doesnt seem to be using + -- the right scale of texture"* -- which it was not, by a factor of `tw`. + local okW, tw = pcall(widest.image.getWidth, widest.image) + local okH, th = pcall(widest.image.getHeight, widest.image) + if not (okW and okH and tw and th and tw > 0 and th > 0) then return false end + + local lo = { math.huge, math.huge, math.huge } + local hi = { -math.huge, -math.huge, -math.huge } + for _, shape in ipairs(self.shapes) do + if shape.lo and shape.hi then + for k = 1, 3 do + if shape.lo[k] < lo[k] then lo[k] = shape.lo[k] end + if shape.hi[k] > hi[k] then hi[k] = shape.hi[k] end + end + end + end + if lo[1] > hi[1] or lo[2] > hi[2] then return false end + if not band then + band = { uLo = flat.u, uHi = flat.u, vLo = flat.v, vHi = flat.v, + vAtFoot = flat.v, yFoot = lo[2], + width = (hi[1] - lo[1]) + 1, height = (hi[2] - lo[2]) + 1, + area = flat.area } + end + + -- A FLAT PANEL IS NOT A BUILDING WITH A MISSING BACK. + -- + -- `door01` passed the north/south test -- a door faces south and has no + -- north side, exactly like a house -- and would have got a wall the size of + -- the door standing behind it. It is 3.9 deep against 19.5 wide; a Twinleaf + -- house is 44 deep against 67. What separates them is DEPTH: a thing with + -- no depth has no inside to close, and its "back" is the wall it is stuck to. + local width = hi[1] - lo[1] + local height = hi[2] - lo[2] + local depth = hi[3] - lo[3] + local across = (width > height) and width or height + if across < 1e-6 or depth < across * 0.25 then return false end + + -- IT FOLLOWS THE BUILDING'S SILHOUETTE, not its bounding box. + -- + -- The first version was one rectangle across the whole box, and the + -- CARTRIDGE view changed by 3,887 pixels when it should not have changed at + -- all: a gabled roof slopes down to the eaves, so a rectangle reaching the + -- RIDGE height sticks out past the roof on both sides, and those corners + -- showed as two red triangles over every house. + -- + -- THE OUTLINE COMES FROM THE WALL TRIANGLES, NOT FROM WHOLE SHAPES. + -- + -- Three separate adjustments -- inset the height, move to the wall line, + -- shrink the box -- each moved the stray-pixel count by about ten, which is + -- the signature of guessing at a cause rather than finding one. The cause + -- is that the ROOF overhangs the walls on every axis, so a silhouette that + -- includes it puts a wall out under the eaves on all sides at once. + -- + -- Excluding whole shapes whose faces mostly point up was the first attempt + -- and it excludes nothing on the buildings that matter: `t1_h01` is ONE + -- shape carrying its walls and its roof together. `wallTris` lists the + -- triangles that are not up- or down-facing, so the silhouette is the walls' + -- and the gable end's -- which is exactly the outline a back should fill. + -- NORTH IS -Z -- AND THE WALL LINE IS NOT THE BOX'S NORTH EDGE. + -- + -- The box's north extent is the ROOF OVERHANG, which reaches further north + -- than the walls do. Placing the back there put it out under the eaves + -- where the roof does not cover it, and it showed from the front as thin + -- vertical slivers at each gable corner -- 259 stray pixels on a view that + -- must not change. Tucking it half a unit lower barely moved that (253), + -- which is what said the fault was depth and not height. + -- + -- Nor is it the SHAPE's north edge, which was the next guess and no better: + -- `t1_h01` is one shape carrying its walls and its roof, so its north edge + -- IS the overhang, at -22.00. + -- + -- Nor is it the plane of the faces wearing the wall COURSE, which was the + -- guess after that and is the one that shipped. Reported from play: + -- *"theyre a bit too far inside of the house rather than fitting on the + -- back"*, and that is exactly right -- `t1_h01`'s chosen course is worn by + -- two quads that stop at -13.75 while the walls carry on to -19.75, so the + -- back stood six units inside its own house. A course is a texture, and + -- where a texture stops is not where a building stops. + -- + -- It is the northmost WALL FACE: every triangle that is not up- or + -- down-facing, which is the same set the silhouette is drawn from. Measured + -- over six buildings it agrees with the northmost east/west face every time + -- and sits 1 to 3 units south of the bounding box -- the roof overhang, + -- which is the one thing it has to exclude. + -- ...AND IT IS NOT SIMPLY THE NORTHMOST WALL FACE EITHER. + -- + -- `c1_b01a`'s northmost wall stands two units proud of the house and is a + -- PLINTH: the cross-section there is eight triangles reaching 11.30 where + -- the building is 102 tall, and a back drawn to it came out with no height + -- and was dropped. So the wall planes are tried from the north inward and + -- the first one where the building is still at least half its own height is + -- taken. There is no slope to sit on: the rejected plane is 11 per cent of + -- the height and the accepted one 96, while every other building measured + -- passes at its very first plane at 100. + local candidates, seenZ = {}, {} + for _, shape in ipairs(self.shapes) do + for _, zw in ipairs(shape.wallZs or {}) do + local q = math.floor(zw * 16 + 0.5) + if not seenZ[q] then seenZ[q] = true; candidates[#candidates + 1] = zw end + end + end + table.sort(candidates) + local enough = lo[2] + (hi[2] - lo[2]) * 0.5 + local wallZ + for _, zc in ipairs(candidates) do + local top = -math.huge + for _, shape in ipairs(self.shapes) do + local pos, tri = shape.outline, shape.tris + for t = 1, (pos and tri and #tri or 0), 3 do + local pa, pb, pc = pos[tri[t]], pos[tri[t + 1]], pos[tri[t + 2]] + if pa and pb and pc then + local zl = (pa[3] < pb[3] and pa[3] or pb[3]) + if pc[3] < zl then zl = pc[3] end + local zh = (pa[3] > pb[3] and pa[3] or pb[3]) + if pc[3] > zh then zh = pc[3] end + if zl <= zc and zh >= zc then + if pa[2] > top then top = pa[2] end + if pb[2] > top then top = pb[2] end + if pc[2] > top then top = pc[2] end + end + end + end + end + if top >= enough then wallZ = zc break end + end + if not wallZ then + for _, shape in ipairs(self.shapes) do + local zw = shape.wallZLo + if zw and (wallZ == nil or zw < wallZ) then wallZ = zw end + end + end + if not wallZ then wallZ = (widest.lo and widest.lo[3]) or lo[3] end + -- The cross-section is taken at the wall plane itself and the quad is drawn + -- a twentieth of a unit south of it. Sectioning at the drawn plane instead + -- drops every flat face that ENDS at the wall line -- zero z extent, so it + -- crosses nothing -- and `c1_b01a`'s back collapsed to no height at all. + local zSection = wallZ + local z = wallZ + 0.05 + + -- ...AND IT IS TUCKED A LITTLE UNDER THE ROOFLINE. + -- + -- The silhouette is sampled on a sixteenth-of-a-unit grid, so a sloping + -- gable leaves a staircase whose corners can stand a fraction proud of the + -- real roof edge. Half a unit of inset puts the wall under the roof rather + -- than through it, and is invisible from behind because the roof covers it. + local INSET = 0.5 + + local xs, seen = {}, {} + for _, shape in ipairs(self.shapes) do + local pos, tri = shape.outline, shape.tris + for t = 1, (pos and tri and #tri or 0), 3 do + local pa, pb, pc = pos[tri[t]], pos[tri[t + 1]], pos[tri[t + 2]] + if pa and pb and pc then + local zlo = (pa[3] < pb[3] and pa[3] or pb[3]) + if pc[3] < zlo then zlo = pc[3] end + local zhi = (pa[3] > pb[3] and pa[3] or pb[3]) + if pc[3] > zhi then zhi = pc[3] end + if zlo <= zSection and zhi >= zSection then + for e = 1, 3 do + local v = (e == 1 and pa) or (e == 2 and pb) or pc + local key = math.floor(v[1] * 16 + 0.5) + if not seen[key] then seen[key] = true; xs[#xs + 1] = key end + end + end + end + end + end + if #xs < 2 then return false end + table.sort(xs) + + + -- THE WALL IS TILED WITH ITS OWN COURSE, not stretched across the span. + -- + -- `t1_h01`'s side wall is TWO quads that share an edge, 11.75 units wide + -- each, and the second one runs its u backwards -- 28 texels forwards, then + -- 28 texels back. The cartridge mirrors the course rather than repeating + -- it, which is why the wall has no visible seam down its middle. The back + -- is tiled the same way, so it matches the sides it joins at the corner. + -- + -- u is therefore a triangle wave, and CONTINUOUS at every band edge, so one + -- value per x is well defined no matter which cell a breakpoint is read in. + -- The wall's own x extent, from the silhouette rather than the box -- the + -- box's is the roof's. + local wallXLo, wallXHi = xs[1] / 16, xs[#xs] / 16 + -- WHAT THE WALL IS FILLED WITH: the band's siding, with its window taken + -- out and set aside to be stamped once. `splitWallBand` returns nil for a + -- band that is siding all the way across, and then the whole band tiles as + -- before. + local split = splitWallBand(widest.imagePath, band, tw, th) + local density = (band.uHi - band.uLo) / band.width + local tileULo = split and split.fillLo or band.uLo + local tileUHi = split and split.fillHi or band.uHi + local tileW = (tileUHi - tileULo) / density + if not (tileW > 1e-6) then tileULo, tileUHi, tileW = band.uLo, band.uHi, band.width end + local uSpan = tileUHi - tileULo + local function uAt(x) + local t = (x - wallXLo) / tileW + local cell = math.floor(t) + local f = t - cell + if cell % 2 ~= 0 then f = 1 - f end + return (tileULo + uSpan * f) / tw + end + -- v REPEATS rather than mirroring, because a course of siding read upside + -- down is not a course of siding. A sawtooth is discontinuous at each row + -- edge, so it is evaluated against a KNOWN row instead of from y alone, and + -- the rows are cut exactly on those edges below. + local vFoot = band.vAtFoot + local vHead = (vFoot == band.vHi) and band.vLo or band.vHi + local function vAt(row, y) + local f = (y - (band.yFoot + row * band.height)) / band.height + if f < 0 then f = 0 elseif f > 1 then f = 1 end + return (vFoot + (vHead - vFoot) * f) / th + end + + -- THE BACK IS NO WIDER THAN THE SIDES IT JOINS. + -- + -- The silhouette is the building's cross-section and it includes the FRONT + -- facade, which on `t1_h01` reaches 32 units either side while the side + -- walls stand at 29. Three units of back wall then stood proud of each + -- corner and showed from the front as a red sliver down both houses -- + -- 3,805 pixels on a view that must not change. + -- + -- Clamping to the outermost east/west planes either does nothing or pulls + -- in: measured over twelve placed buildings it never widened one. + do + -- ...AND ONLY THE SIDE WALLS THAT REACH THE BACK PLANE. + -- + -- A building with a wide front and a narrow back has no single width, and + -- taking the widest anywhere left a back wall standing several pixels + -- proud of the near corner of the house closest to the camera -- 1,592 of + -- the 1,757 stray pixels left in the front view came from that one + -- building. + -- A CLAMP THAT MAY DECLINE. + -- + -- The section's own width is the roof's, so it has to be pulled in to the + -- walls -- but only where the wall being pulled to is the SAME wall. The + -- side planes crossing the back are tried first and the outermost planes + -- second, and each side takes the first candidate that moves it by no more + -- than a tenth of the section's width. + -- + -- Insisting on the crossing planes was tried and it is far too strong: + -- `t1_s01`, `t1_s02` and `t2_s02` have nothing at the back plane but a + -- narrow pair of faces, and their backs collapsed to a 7.5-unit sliver on + -- a 90-unit house, while `t2_s01`'s lost its whole right half. + local secLo, secHi = xs[1] / 16, xs[#xs] / 16 + local limit = (secHi - secLo) * 0.1 + local crossLo, crossHi, anyLo, anyHi + for _, shape in ipairs(self.shapes) do + local pl = shape.sidePlanes + for i = 1, (pl and #pl or 0), 3 do + local px = pl[i] + anyLo = (anyLo == nil or px < anyLo) and px or anyLo + anyHi = (anyHi == nil or px > anyHi) and px or anyHi + if pl[i + 1] <= zSection and pl[i + 2] >= zSection then + crossLo = (crossLo == nil or px < crossLo) and px or crossLo + crossHi = (crossHi == nil or px > crossHi) and px or crossHi + end + end + end + -- + -- The tenth-of-the-width test chooses BETWEEN the two candidates; it does + -- not decide whether to clamp at all. A back wider than every side wall + -- the building owns is wrong however far apart they are, so the outermost + -- plane is taken unconditionally when neither candidate is close -- which + -- is what was left showing as a four-pixel strip down the near corner of + -- the house closest to the camera, on a building whose roof overhangs by + -- more than a tenth of its width. + local function pick(current, cross, any, inward) + if cross and (cross - current) * inward > 0 + and (cross - current) * inward <= limit then return cross end + if any and (any - current) * inward > 0 + and (any - current) * inward <= limit then return any end + if any and (any - current) * inward > 0 then return any end + return current + end + local sxLo = pick(secLo, crossLo, anyLo, 1) + local sxHi = pick(secHi, crossHi, anyHi, -1) + -- ...AND THE CLADDING'S OWN PLANE BEATS THE OUTERMOST FACE WHEN THEY ARE + -- THE SAME WALL. + -- + -- `t1_h01`'s outermost east/west faces stand at 29.00 and its clapboard at + -- 28.00: a one-unit strip of trim on the same wall. A back drawn to 29 + -- reaches a unit past the cladding and is visible from the front as a + -- clapboard sliver down the house's corner, which is what 846 stray pixels + -- looked like when cropped and enlarged. + -- + -- A tenth of the building's width separates the two cases cleanly on the + -- twelve buildings placed here: where the course's plane and the outermost + -- plane are the same wall they differ by 0.04 to 5 per cent (t1_h01 1.7, + -- c1_b01 3, c1_school1 2 to 3.5, t1_s01 0.7 to 5), and where they are + -- genuinely different walls -- a course worn only by a narrow pair of + -- faces, or a wing that the course does not reach -- they differ by 14, 37 + -- and 45. Nothing measured falls between. + if sxLo and sxHi and sxHi > sxLo and band.xLo and band.xHi then + local span = (sxHi - sxLo) * 0.1 + if band.xLo > sxLo and (band.xLo - sxLo) <= span then sxLo = band.xLo end + if band.xHi < sxHi and (sxHi - band.xHi) <= span then sxHi = band.xHi end + end + if sxLo and sxHi and sxHi > sxLo then + local kLo = math.floor(sxLo * 16 + 0.5) + local kHi = math.floor(sxHi * 16 + 0.5) + if kLo > xs[1] or kHi < xs[#xs] then + local keep = {} + for _, k in ipairs(xs) do + if k > kLo and k < kHi then keep[#keep + 1] = k end + end + table.insert(keep, 1, kLo) + keep[#keep + 1] = kHi + xs = keep + end + wallXLo, wallXHi = kLo / 16, kHi / 16 + end + end + + -- Cut the wall on the band edges as well as the silhouette's corners, so + -- every cell carries one unbroken run of the course. + do + local extra = {} + local k = 1 + while true do + local x = wallXLo + k * tileW + if x >= wallXHi - 1e-6 then break end + local key = math.floor(x * 16 + 0.5) + if not seen[key] then seen[key] = true; extra[#extra + 1] = key end + k = k + 1 + if k > 4096 then break end + end + for _, key in ipairs(extra) do xs[#xs + 1] = key end + if #extra > 0 then table.sort(xs) end + end + + -- THE ROOFLINE, AS THE UPPER ENVELOPE OF THE CROSS-SECTION AT z. + -- + -- Taking the highest VERTEX at each sampled x is not the outline -- it is a + -- sample of it, and the two disagree wherever a triangle spans an x that + -- carries no vertex of its own. Looked at from behind, that showed as two + -- pale spikes standing above `t1_h01`'s gable and a dark wedge beside them + -- where the wall stopped short and the roof's underside showed through. + -- + -- Asking each triangle how high it reaches AT this x answers the same + -- question exactly: the envelope's breakpoints are triangle corners, so + -- sampling at every corner and at every band edge loses nothing between + -- them. + -- + -- AND ONLY THE TRIANGLES THAT CROSS THE BACK PLANE, which is the fix that + -- the raised inset and the three rear-plane choices could not be: taking the + -- outline over the WHOLE model gives the building's tallest cross-section + -- anywhere, and Twinleaf's two-storey houses are tall at the FRONT and low + -- at the back. Their backs were drawn to the front block's height and stood + -- up behind their own roofs -- 312 stray pixels in the cartridge view, + -- entirely on those houses, with the single-storey ones clean. Raising the + -- inset eightfold only took it to 217, which is what said the height was not + -- the fault. + -- + -- The roof is included here where the walls are not. Over the hole the back + -- is closing there IS no wall, so the roof's underside is the only thing + -- that states the shape of the opening -- and it states it exactly: on + -- `t1_h01` the cross-section reads 72.50 at the ridge and 50.63 at the + -- cladding line, which is the gable. + local column = {} + do + local kept = {} + for _, key in ipairs(xs) do + -- `best` is the section's answer; `spare` is the whole model's, used + -- only where the section has nothing to say. + -- + -- `c1_b01a`'s northmost wall face is a low plinth standing two units + -- proud of the house, so the section at the back plane is eight + -- triangles reaching 11.30 where the building is 98 tall -- and the back + -- came out with no height at all and was dropped. Where the section is + -- silent the model's own outline answers instead, which is what this did + -- before the section existed. + local x, best, spare = key / 16, nil, nil + for _, shape in ipairs(self.shapes) do + local pos, tri = shape.outline, shape.tris + for t = 1, (pos and tri and #tri or 0), 3 do + local pa, pb, pc = pos[tri[t]], pos[tri[t + 1]], pos[tri[t + 2]] + if pa and pb and pc then + local xa, xb, xc = pa[1], pb[1], pc[1] + local xlo = (xa < xb and xa or xb); if xc < xlo then xlo = xc end + local xhi = (xa > xb and xa or xb); if xc > xhi then xhi = xc end + local zlo = (pa[3] < pb[3] and pa[3] or pb[3]) + if pc[3] < zlo then zlo = pc[3] end + local zhi = (pa[3] > pb[3] and pa[3] or pb[3]) + if pc[3] > zhi then zhi = pc[3] end + local crosses = (zlo <= zSection and zhi >= zSection) + if x >= xlo - 0.03125 and x <= xhi + 0.03125 then + -- the three edges, and any corner standing on this x + for e = 1, 3 do + local p = (e == 1 and pa) or (e == 2 and pb) or pc + local q = (e == 1 and pb) or (e == 2 and pc) or pa + local dx = q[1] - p[1] + if dx > 1e-6 or dx < -1e-6 then + local f = (x - p[1]) / dx + if f >= 0 and f <= 1 then + local y = p[2] + (q[2] - p[2]) * f + if crosses then + if best == nil or y > best then best = y end + elseif spare == nil or y > spare then spare = y + end + end + end + if p[1] >= x - 0.03125 and p[1] <= x + 0.03125 then + if crosses then + if best == nil or p[2] > best then best = p[2] end + elseif spare == nil or p[2] > spare then spare = p[2] + end + end + end + end + end + end + end + if best == nil then best = spare end + if best then + local y = best - INSET + column[key] = (y > lo[2]) and y or lo[2] + kept[#kept + 1] = key + end + end + xs = kept + end + if #xs < 2 then return false end + + -- IS THERE ALREADY A BACK HERE? Asked against the opening, not by counting + -- triangles. + -- + -- The old test -- north-facing triangles must be under a tenth of the + -- south-facing ones -- threw out the Pokemon Centre and the Poke Mart, the + -- two buildings in Sinnoh a player walks behind most, and it threw them out + -- for the wrong reason twice over: their roofs contributed most of those + -- "north" faces, and what vertical north wall they do have covers only part + -- of the opening. Reported: *"the pokemarts, and pokemon centers are + -- missing backs still"*. + -- + -- Comparing like with like instead: the wall area that already faces north + -- AT the back plane, against the area this would add. A model whose back is + -- genuinely modelled covers its own opening and is left alone; one that + -- covers a third of it gets the rest. + do + local opening = 0 + for i = 1, #xs - 1 do + local w = (xs[i + 1] - xs[i]) / 16 + local h0 = column[xs[i]] - lo[2] + local h1 = column[xs[i + 1]] - lo[2] + opening = opening + w * (h0 + h1) * 0.5 + end + local covered = 0 + for _, shape in ipairs(self.shapes) do + local nw = shape.northWalls + for i = 1, (nw and #nw or 0), 2 do + if nw[i] <= wallZ + 1 and nw[i] >= wallZ - 1 then + covered = covered + nw[i + 1] + end + end + end + if opening <= 1e-6 or covered >= opening * 0.9 then return false end + end + + local rowLo = math.floor((lo[2] - band.yFoot) / band.height) + local vertices, map = {}, {} + for i = 1, #xs - 1 do + local x0, x1 = xs[i] / 16, xs[i + 1] / 16 + local y0, y1 = column[xs[i]], column[xs[i + 1]] + local top = (y0 > y1) and y0 or y1 + local rowHi = math.floor((top - band.yFoot) / band.height) + for row = rowLo, rowHi do + local rb = band.yFoot + row * band.height + local rt = rb + band.height + -- Each corner is clipped to the roofline above it and the ground below, + -- so a gable ends as a wedge rather than a staircase. + local function clip(y, cap) + if y < lo[2] then return lo[2] end + if y > cap then return cap end + return y + end + local a0, a1 = clip(rb, y0), clip(rt, y0) + local b0, b1 = clip(rb, y1), clip(rt, y1) + if (a1 - a0) > 1e-4 or (b1 - b0) > 1e-4 then + local ua, ub = uAt(x0), uAt(x1) + local base = #vertices + vertices[#vertices + 1] = + { x0, a0, z, ua, vAt(row, a0), 1, 1, 1, 1, 0, 0, 0 } + vertices[#vertices + 1] = + { x1, b0, z, ub, vAt(row, b0), 1, 1, 1, 1, 0, 0, 0 } + vertices[#vertices + 1] = + { x1, b1, z, ub, vAt(row, b1), 1, 1, 1, 1, 0, 0, 0 } + vertices[#vertices + 1] = + { x0, a1, z, ua, vAt(row, a1), 1, 1, 1, 1, 0, 0, 0 } + map[#map+1] = base + 1; map[#map+1] = base + 2; map[#map+1] = base + 3 + map[#map+1] = base + 1; map[#map+1] = base + 3; map[#map+1] = base + 4 + end + end + end + -- ONE WINDOW, where the cartridge puts one. + -- + -- Stamped rather than tiled, at the height the side wall carries it and + -- centred across the back, with the band's own full v range so its courses + -- run straight into the siding either side of it. It sits a fiftieth of a + -- unit north of the wall, which is in front of it from every angle that can + -- see the back at all. + if split and #vertices > 0 then + local featW = (split.featHi - split.featLo) / density + local mid = (wallXLo + wallXHi) * 0.5 + local fx0, fx1 = mid - featW * 0.5, mid + featW * 0.5 + local fy0 = band.yFoot + local fy1 = fy0 + band.height + -- Only where there is wall to put it on: it must fit between the corners + -- and stand below the roofline at both of its own edges. + local room = true + if not (featW > 1e-6 and fx0 > wallXLo and fx1 < wallXHi and fy0 >= lo[2]) then + room = false + else + for _, key in ipairs(xs) do + local kx = key / 16 + if kx >= fx0 - 1e-6 and kx <= fx1 + 1e-6 and column[key] < fy1 then + room = false + break + end + end + end + if room then + local fz = z - 0.02 + local ua, ub = split.featLo / tw, split.featHi / tw + local va, vb = vAt(0, fy0), vAt(0, fy1) + local base = #vertices + vertices[#vertices + 1] = { fx0, fy0, fz, ua, va, 1, 1, 1, 1, 0, 0, 0 } + vertices[#vertices + 1] = { fx1, fy0, fz, ub, va, 1, 1, 1, 1, 0, 0, 0 } + vertices[#vertices + 1] = { fx1, fy1, fz, ub, vb, 1, 1, 1, 1, 0, 0, 0 } + vertices[#vertices + 1] = { fx0, fy1, fz, ua, vb, 1, 1, 1, 1, 0, 0, 0 } + map[#map+1] = base + 1; map[#map+1] = base + 2; map[#map+1] = base + 3 + map[#map+1] = base + 1; map[#map+1] = base + 3; map[#map+1] = base + 4 + end + end + + if #vertices == 0 then return false end + local okMesh, mesh = + pcall(love.graphics.newMesh, FORMAT, vertices, "triangles", "static") + if not (okMesh and mesh) then return false end + pcall(mesh.setVertexMap, mesh, map) + mesh:setTexture(widest.image) + self.shapes[#self.shapes + 1] = { + mesh = mesh, name = "backWall", index = nil, + material = widest.material, image = widest.image, + lo = { lo[1], lo[2], z }, hi = { hi[1], hi[2], z }, + } + return true +end + +-- addGroundShadow(model, path) -> did it add one? +-- +-- Requested: *"implement shadows for indoor objects"*. +-- +-- THE CARTRIDGE DOES NOT HAVE THESE, and that was measured before writing a +-- line of it. 153 of the 590 prop models carry real shadow geometry -- a +-- material named `h_kage`, which this file already gives a reduced alpha -- +-- but they are the OUTDOOR props. Of the models actually placed on maps, the +-- most common are interior furniture and every one of them carries none: +-- `ref01` 203 placements, `sofa01` 109, `table02` 82, `chair03` 78, `bed_h01` +-- 57, `plant01` 34 -- **zero shadow shapes each**. +-- +-- So this is GENERATED, not extracted, and it is only added where the +-- cartridge left a gap: a model that already has a `kage` shape keeps its own. +-- +-- Added as a SHAPE on the model rather than as a draw in the ground pass, +-- because the props are drawn from four different places (the bake, the live +-- pass, the canopy pass and the free pass) and a shadow that has to be added +-- to each is a shadow that will be missing from one. +local function addGroundShadow(self, path) + for _, shape in ipairs(self.shapes) do + if tostring(shape.material or ""):lower():find("kage", 1, true) then + return false + end + end + local lo = { math.huge, math.huge, math.huge } + local hi = { -math.huge, -math.huge, -math.huge } + for _, shape in ipairs(self.shapes) do + if shape.lo and shape.hi then + for k = 1, 3 do + if shape.lo[k] < lo[k] then lo[k] = shape.lo[k] end + if shape.hi[k] > hi[k] then hi[k] = shape.hi[k] end + end + end + end + -- A model whose shapes reported no box at all has nothing to sit under. + if lo[1] > hi[1] or lo[3] > hi[3] then return false end + local okImage, image = pcall(Assets.image, path) + if not (okImage and image) then return false end + + -- THE TRANSLUCENCY IS CARRIED ON THE VERTEX, not left to `shapeAlpha`. + -- + -- Measured: with the alpha coming only from the material name, the shadow + -- painted 0.161,0.192,0.259 over a PALE floor and the identical colour over + -- a DARK one -- the same patch either way, which is what opaque looks like. + -- A plain 29%-alpha rectangle in the same harness reads 0.608 over that pale + -- floor, so blending itself is working; the alpha simply was not arriving. + -- + -- The shader multiplies `texel * colour` and `colour` is LOVE's vertex + -- colour times the draw colour, so putting it here makes it independent of + -- what `shapeAlpha` decides -- and a generated shadow should not depend on + -- being named well enough to be recognised. + local a = SHADOW_ALPHA_FRACTION + local y = lo[2] + SHADOW_LIFT + local vertices = { + { lo[1], y, lo[3], 0, 0, 1, 1, 1, a, 0, 0, 0 }, + { hi[1], y, lo[3], 1, 0, 1, 1, 1, a, 0, 0, 0 }, + { hi[1], y, hi[3], 1, 1, 1, 1, 1, a, 0, 0, 0 }, + { lo[1], y, hi[3], 0, 1, 1, 1, 1, a, 0, 0, 0 }, + } + local okMesh, mesh = + pcall(love.graphics.newMesh, FORMAT, vertices, "triangles", "static") + if not (okMesh and mesh) then return false end + pcall(mesh.setVertexMap, mesh, { 1, 2, 3, 1, 3, 4 }) + mesh:setTexture(image) + self.shapes[#self.shapes + 1] = { + mesh = mesh, name = "groundShadow", + -- NO INDEX, so `pose[shape.index]` finds nothing and the shadow is placed + -- by the model's own matrix. A posed model -- most of them pose to + -- identity -- would otherwise need a joint this shape does not belong to. + index = nil, + -- NAMED `kage` on purpose: `shapeAlpha` reads that and gives it the + -- cartridge's own shadow translucency, so a generated shadow is exactly as + -- dark as an extracted one. + material = "kage", + image = image, + lo = { lo[1], y, lo[3] }, hi = { hi[1], y, hi[3] }, + } + return true +end + +function Gen4Model.new(record) + if type(record) ~= "table" or type(record.shapes) ~= "table" then return nil end + if not ensureShader() then return nil end + + local self = setmetatable({}, Gen4Model) + self.name = record.name + self.posScale = record.posScale or 1 + self.bounds = record.bounds + self.shapes = {} + -- The model's own node transforms and the bytecode that arranges them. A + -- model whose nodes are all identity -- most of them -- poses to identity + -- and costs nothing; the briefcase's do the work. + self.nodes = record.nodes or {} + self.ops = record.ops or {} + + for _, shape in ipairs(record.shapes) do + local image + if shape.image then + local ok, loaded = pcall(Assets.image, shape.image) + if ok and loaded then + image = loaded + -- Nearest, always: these are 16 and 64 pixel textures on a model that + -- will be drawn several times their own size, and smoothing them turns + -- a Poke Ball's seam into a smear. + image:setFilter("nearest", "nearest") + -- the material's own edge mode (Gen4ModelPack.wrapOf); repeat when + -- the cache says nothing, which is what every model assumed before + local wrap = shape.wrap + image:setWrap(wrap and wrap[1] or "repeat", wrap and wrap[2] or "repeat") + end + end + + -- The texture's own size is what turns a coordinate in sixteenths of a + -- texel into the 0-1 the sampler wants. Without an image there is nothing + -- to divide by and the shape draws untextured, which is what an untextured + -- material means anyway. + local tw, th = 1, 1 + if image then tw, th = image:getDimensions() end + + local count = shape.vertexCount or 0 + local vertices = {} + -- Kept alongside, because classifying a card needs the whole triangle and + -- the loop below sees one vertex at a time. + -- Also collected for the back-wall cap, which needs the face normals to + -- know whether a back is missing and which wall is a plain side. + local positions = (record.billboard or record.capBack) and {} or nil + local grassPending + local uLo, uHi = math.huge, -math.huge + -- THE STRIDE THIS CACHE WAS WRITTEN WITH, measured off the record rather + -- than assumed from the constant. + -- + -- The vertex grew from 14 bytes to 16 when the normal was added, and a + -- cache imported before that change is still on disk in every install that + -- has not re-imported. Reading a 14-byte buffer at a 16-byte stride does + -- not fail -- it misaligns EVERY vertex after the first, so the world comes + -- back as confetti with no error anywhere. Dividing the buffer by the + -- vertex count is the one question that has a right answer on both. + local stride = VERTEX_BYTES + if count > 0 and shape.vertices then + local measured = math.floor(#shape.vertices / count) + if measured == LEGACY_VERTEX_BYTES or measured == VERTEX_BYTES then + stride = measured + end + end + local lit = (stride >= VERTEX_BYTES) + -- THE LIGHT, if this caller brought one. `shade(nx, ny, nz)` answers a + -- multiplier per channel and is built by `Gen4Shade` from the map's own + -- area-light template -- which is why it arrives as an argument rather + -- than being looked up here: this file is handed shapes by the ground + -- (which knows the map) and by screens that have no map at all, and a + -- model with no light must keep drawing exactly as it did. + local shade = record.shade + -- ...and the map's fog, which paints every shape that has not opted out + -- (Gen4ModelPack `noFog`) in its colour + local fog = record.fog + -- The shape's own box, kept while the vertices are still here. It is what + -- `framing` works from: the header states a bounding box too, but it does + -- not agree with the geometry on two thirds of this cartridge's models, so + -- the measured one is the one to trust. + local lo = { math.huge, math.huge, math.huge } + local hi = { -math.huge, -math.huge, -math.huge } + for i = 0, count - 1 do + local at = i * stride + local r, g, b = shape.vertices:byte(at + 11, at + 13) + r, g, b = r or 255, g or 255, b or 255 + -- ...not on a shape with no NORMALs (Gen4ModelPack `unlit`): + -- its vertex colours ARE its lighting + if shade and lit and not shape.unlit then + -- The normal, back out of its three signed bytes. Absent on a cache + -- imported before the stride changed, and `s8` answers nil there -- + -- which leaves the colour alone rather than multiplying it by a + -- normal that is really the first two bytes of the next vertex. + local nx, ny, nz = s8(shape.vertices, at + 13), + s8(shape.vertices, at + 14), + s8(shape.vertices, at + 15) + if nx and ny and nz then + local lr, lg, lb = shade(nx, ny, nz) + r = r * (lr or 1) + g = g * (lg or 1) + b = b * (lb or 1) + end + end + if fog and not shape.noFog then + r, g, b = (fog[1] or 0) * 255, (fog[2] or 0) * 255, (fog[3] or 0) * 255 + end + local x = s16(shape.vertices, at) / FX16 * self.posScale + local y = s16(shape.vertices, at + 2) / FX16 * self.posScale + local z = s16(shape.vertices, at + 4) / FX16 * self.posScale + if x < lo[1] then lo[1] = x end + if y < lo[2] then lo[2] = y end + if z < lo[3] then lo[3] = z end + if x > hi[1] then hi[1] = x end + if y > hi[2] then hi[2] = y end + if z > hi[3] then hi[3] = z end + vertices[i + 1] = { + x, y, z, + s16(shape.vertices, at + 6) / UV_UNITS / tw, + s16(shape.vertices, at + 8) / UV_UNITS / th, + -- ...and back to the 0..1 the float attribute wants. + r / 255, g / 255, b / 255, 1, + -- filled in below, once the triangles say which vertices are cards + 0, 0, 0, + } + if positions then + -- the TEXEL coordinate, not the 0..1 one: the strip test compares it + -- against the texture's own width, so it has to be in texture units. + local texel = s16(shape.vertices, at + 6) / UV_UNITS + -- ...AND THE TEXEL V, which the back wall's band needs. Kept beside + -- the u rather than derived later because this is the only place the + -- raw vertex is still in hand. + positions[i + 1] = { x, y, z, texel, + s16(shape.vertices, at + 8) / UV_UNITS } + if texel < uLo then uLo = texel end + if texel > uHi then uHi = texel end + end + end + + -- WHICH OF THESE ARE CARDS, and where each one turns. + -- + -- Only when the caller asked: `record.billboard` is set for terrain chunks + -- and NOT for buildings, and that is measured rather than tidy. Run over + -- `build_model.narc` the same classifier catches 184 triangles -- a school + -- roof, a fountain, a shop front -- that happen to sit at the tree's lean + -- and would then swing to face the camera. The cartridge's trees live in + -- the terrain, so that is where the rule is allowed to apply. + if positions then + -- ONLY WHEN THE TEXTURE REALLY LOADED. `tw` falls back to 1, and a + -- width of 1 would make the strip test reject every card with a u span + -- over 1.5 texels -- which is all of them. Passing nil there skips the + -- test and billboards on the geometry alone, which is what a cache with + -- no textures should do rather than silently doing nothing. + local pivots, cards = + billboardPivots(shape, positions, count, image and tw or nil) + if cards > 0 then + for i = 1, count do + local v, pv = vertices[i], pivots[i] + v[10], v[11], v[12] = pv[1], pv[2], pv[3] + end + self.cards = (self.cards or 0) + cards + end + -- ...AND THE GRASS STANDS UP, where the material says a wild Pokemon + -- lives in it. The flat quad stays: from the cartridge's own camera the + -- card is edge-on and what you see is still the ground tile. + if record.tileUnits and record.tileUnits > 0 + and GRASS_MATERIALS[tostring(shape.texture or "")] then + grassPending = { shape = shape, positions = positions } + end + end + if stride == LEGACY_VERTEX_BYTES and not Gen4Model.saidLegacy then + Gen4Model.saidLegacy = true + Logger.warn("gen4 model: this cache's vertices are %d bytes and carry no " + .. "normal, so nothing can be lit -- the world will draw " + .. "correctly and FLAT until the ROM is imported again", + LEGACY_VERTEX_BYTES) + end + + if #vertices > 0 then + local map = {} + for i = 0, (shape.triangleCount or 0) * 3 - 1 do + local a, b = shape.indices:byte(i * 2 + 1, i * 2 + 2) + -- Back to one-based, which is what setVertexMap takes. + map[i + 1] = (a + b * 256) + 1 + end + + local okMesh, mesh = + pcall(love.graphics.newMesh, FORMAT, vertices, "triangles", "static") + if okMesh and mesh then + if #map > 0 then pcall(mesh.setVertexMap, mesh, map) end + if image then mesh:setTexture(image) end + -- WHICH WAY THIS SHAPE'S FACES POINT, and how densely it is textured. + -- + -- Both are for the back-wall cap: the counts say whether a model has a + -- north side at all, and the density lets a generated wall wear the + -- side wall's texture at the side wall's own scale rather than + -- stretched across the span. + local facing, wallBand, bands, tris, sideXLo, sideXHi, wallZLo + local sidePlanes, wallZs, northArea, southArea, northWalls, flatBand + if record.capBack and positions then + facing = { north = 0, south = 0, east = 0, west = 0, up = 0, down = 0 } + local indices = shape.indices or "" + for t = 0, (shape.triangleCount or 0) - 1 do + local at2 = t * 6 + local a1, a2, b1, b2, c1, c2 = indices:byte(at2 + 1, at2 + 6) + if not c2 then break end + local pa = positions[a1 + a2 * 256 + 1] + local pb = positions[b1 + b2 * 256 + 1] + local pc = positions[c1 + c2 * 256 + 1] + if pa and pb and pc then + -- EVERY triangle is kept, roof included. The back plugs the + -- building's CROSS-SECTION at the back plane, and over the hole + -- it is closing the only thing there is to measure is the roof. + tris = tris or {} + tris[#tris + 1] = a1 + a2 * 256 + 1 + tris[#tris + 1] = b1 + b2 * 256 + 1 + tris[#tris + 1] = c1 + c2 * 256 + 1 + local ux, uy, uz = pb[1]-pa[1], pb[2]-pa[2], pb[3]-pa[3] + local vx, vy, vz = pc[1]-pa[1], pc[2]-pa[2], pc[3]-pa[3] + local nx = uy*vz - uz*vy + local ny = uz*vx - ux*vz + local nz = ux*vy - uy*vx + local len = math.sqrt(nx*nx + ny*ny + nz*nz) + if len > 1e-6 then + nx, ny, nz = nx/len, ny/len, nz/len + local ax, ay, az = math.abs(nx), math.abs(ny), math.abs(nz) + local upright = (ay >= ax and ay >= az) + if upright then + facing[ny > 0 and "up" or "down"] = + facing[ny > 0 and "up" or "down"] + 1 + else + -- A WALL VERTEX, and the back's silhouette is drawn from + -- these alone. + -- + -- The outline used to be every vertex of every shape that + -- was not MOSTLY roof, which is a per-shape test -- and + -- `t1_h01` is a single shape carrying its walls and its roof + -- together, so nothing was ever excluded and the silhouette + -- was the ROOF's. The back then reached out under the eaves + -- on both sides and showed from the FRONT as red slivers + -- down each house: 9,742 pixels on a view that must not + -- change at all. + -- + -- Sorting triangles rather than shapes costs nothing here -- + -- the normals are already in hand for `facing` -- and gives + -- the outline of the WALLS, which is the thing a back wall + -- has to match. + -- HOW FAR NORTH THE WALLS REACH. North is -z. + local zw = pa[3] + if pb[3] < zw then zw = pb[3] end + if pc[3] < zw then zw = pc[3] end + if wallZLo == nil or zw < wallZLo then wallZLo = zw end + wallZs = wallZs or {} + wallZs[#wallZs + 1] = zw + end + if upright then -- counted above + elseif az >= ax then + facing[nz > 0 and "south" or "north"] = + facing[nz > 0 and "south" or "north"] + 1 + -- A ROOF SLOPE IS NOT A WALL, and the counts above cannot + -- tell them apart: a rear roof pitch leaning more north than + -- up is filed as "north" here. Measured, the two do not + -- overlap at all -- every vertical wall in this cartridge + -- reads |ny| = 0.00 or 0.01, and every sloping face between + -- 0.34 and 0.69 -- so the walls are counted separately, by + -- AREA, which is the quantity the coverage test needs. + if ay < 0.2 then + local tarea = len * 0.5 + if nz > 0 then + southArea = (southArea or 0) + tarea + else + northArea = (northArea or 0) + tarea + local zn = pa[3] + if pb[3] < zn then zn = pb[3] end + if pc[3] < zn then zn = pc[3] end + northWalls = northWalls or {} + northWalls[#northWalls + 1] = zn + northWalls[#northWalls + 1] = tarea + end + end + else + facing[nx > 0 and "east" or "west"] = + facing[nx > 0 and "east" or "west"] + 1 + -- THE OUTERMOST PLANES AT WHICH THIS MODEL HAS A SIDE WALL. + -- Every east/west face, gated or not: a back wider than the + -- sides it joins shows past the corner from the FRONT, and + -- this is the only measurement that says where they are. + sideXLo = (sideXLo == nil or pa[1] < sideXLo) and pa[1] or sideXLo + sideXHi = (sideXHi == nil or pa[1] > sideXHi) and pa[1] or sideXHi + -- ...AND THE STRETCH OF z EACH ONE COVERS, because a + -- building that is wide at the front and narrow at the back + -- has no single width. Kept flat, three numbers per face. + local zs = pa[3]; local ze = pa[3] + if pb[3] < zs then zs = pb[3] end + if pc[3] < zs then zs = pc[3] end + if pb[3] > ze then ze = pb[3] end + if pc[3] > ze then ze = pc[3] end + sidePlanes = sidePlanes or {} + sidePlanes[#sidePlanes + 1] = pa[1] + sidePlanes[#sidePlanes + 1] = zs + sidePlanes[#sidePlanes + 1] = ze + -- THE COURSE THIS BUILDING'S WALLS ARE CLAD IN, as a + -- rectangle in both world and texel space. + -- + -- The number this replaces was an aggregate: the shape's + -- whole u range over its whole span. On `t1_h01` that is + -- 93.9 texels over 64 units, because `polygon0` carries the + -- porch and the chimney as well as the walls -- while a real + -- wall quad measures 28 texels over 11.75 units. An + -- aggregate of unrelated faces is not a density. + -- + -- A side wall's triangles are right halves of axis-aligned + -- quads, so ONE triangle's own z/y extent IS the quad's. + -- + -- WHICH quad the back should copy is decided by two measured + -- things, and neither is "the biggest one". + -- + -- Biggest-in-world was tried and it is wrong on every + -- building measured. `t1_h01`'s largest side face is 12.00 x + -- 32.50 units showing 5 x 6 texels -- a flat colour stretched + -- over a panel -- while the clapboard course beside it is + -- 11.75 x 28.50 showing 28 x 30. `c1_b02b`'s largest is 48 x + -- 72 units showing 0.9 x 1.6. Tiled from those the back came + -- out a flat dark grey, which is exactly what one texel + -- stretched sixty units wide looks like. + -- + -- Biggest-in-TEXELS was tried next and it is wrong too: on + -- `t1_s01` it picks a shape whose material is named `light` + -- and which wears the whole 64 x 64 atlas on a single quad -- + -- a decal, not a course -- and the back came back covered in + -- smeared windows. + -- + -- WHAT ACTUALLY SEPARATES THEM IS SCALE. Platinum's building + -- art is drawn at screen scale, so a course of siding is + -- about ONE TEXEL PER WORLD UNIT vertically. Measured across + -- nine buildings, every real wall course lands between 0.91 + -- and 1.58 (t1_h01 1.05, c1_b02b 1.13, t1_s01 1.13, c1_s02 + -- 1.09, l2_s02b 0.91, gym00 1.58, r212s02 1.21, shelf08 0.96) + -- while the stretched panels sit at 0.02 to 0.21 and the + -- squeezed decals at 2.06 to 7.53. A window of one octave + -- either side of the drawing scale keeps the courses and + -- drops both kinds of junk. + -- + -- Only the VERTICAL density is gated. Courses run + -- horizontally, so their height is what is drawn to scale; + -- their width may be squeezed because it tiles, and `t1_h01` + -- squeezes it to 2.38. + -- + -- It is a scale window and not a proof. The nearest thing it + -- excludes is that `light` face at 2.06, which is close to the + -- bound; a building whose siding is drawn at half scale would + -- be rejected by this and would need a different rule. + local zA = pa[3]; local zB = pb[3]; local zC = pc[3] + local zMin = (zA < zB and zA or zB); if zC < zMin then zMin = zC end + local zMax = (zA > zB and zA or zB); if zC > zMax then zMax = zC end + local yA = pa[2]; local yB = pb[2]; local yC = pc[2] + local yMin = (yA < yB and yA or yB); if yC < yMin then yMin = yC end + local yMax = (yA > yB and yA or yB); if yC > yMax then yMax = yC end + local uA, uB, uC = pa[4], pb[4], pc[4] + local vA, vB, vC = pa[5], pb[5], pc[5] + local uMin = (uA < uB and uA or uB); if uC < uMin then uMin = uC end + local uMax = (uA > uB and uA or uB); if uC > uMax then uMax = uC end + local vMin = (vA < vB and vA or vB); if vC < vMin then vMin = vC end + local vMax = (vA > vB and vA or vB); if vC > vMax then vMax = vC end + local dz, dy = zMax - zMin, yMax - yMin + local dens = (dy > 1e-6) and ((vMax - vMin) / dy) or 0 + -- THE BIGGEST SIDE WALL, whatever its texture does. + -- + -- Four of Sinnoh's shops -- the Veilstone restaurant, the + -- Hearthome market, `c7_s04` and `c10_s02` -- are not clad + -- in a course at all: their side walls carry a u span of + -- 0.0 texels, so the cartridge paints them from a single + -- texel and there is no scale to match. They got no back. + -- Where there is no course to copy, the back is painted from + -- that same texel, which fits in exactly because it is the + -- colour the walls already are. + if dz * dy > (flatBand and flatBand.area or 0) then + flatBand = { area = dz * dy, u = uA, v = vA, + width = dz, height = dy, yFoot = yMin } + end + if dz > 1e-6 and dy > 1e-6 + and (uMax - uMin) >= WALL_MIN_TEXELS + and (vMax - vMin) >= WALL_MIN_TEXELS + and dens >= WALL_SCALE_LO and dens <= WALL_SCALE_HI then + -- ...AND THE SAME RECT ON MORE FACES BEATS A BIGGER ONE ON + -- fewer, because a course is what a building REPEATS. The + -- world area is summed per distinct texel rect. + bands = bands or {} + local key = ("%d,%d,%d,%d"):format( + uMin * 16 + 0.5, uMax * 16 + 0.5, + vMin * 16 + 0.5, vMax * 16 + 0.5) + local b = bands[key] + if not b then + -- WHICH END OF V IS THE BOTTOM, read off the face rather + -- than assumed: Gen 4 textures run v downward, so the + -- wall's foot carries the LARGER v -- but that is a + -- convention, and the vertex says it outright. + local vFoot = vA + if yB < yA and yB <= yC then vFoot = vB + elseif yC < yA and yC < yB then vFoot = vC end + b = { area = 0, width = dz, height = dy, + uLo = uMin, uHi = uMax, vLo = vMin, vHi = vMax, + vAtFoot = vFoot, yFoot = yMin, + xLo = math.huge, xHi = -math.huge, + zLo = math.huge } + bands[key] = b + end + b.area = b.area + dz * dy + -- WHERE THE SIDE WALLS STAND. An east/west face is planar + -- in x, so the faces wearing this course mark the two + -- planes the back has to span -- and NOT one unit further, + -- which is what the model's bounding box would have given. + if pa[1] < b.xLo then b.xLo = pa[1] end + if pa[1] > b.xHi then b.xHi = pa[1] end + -- ...AND HOW FAR NORTH THEY REACH. North is -z, so the + -- smallest z on the faces wearing this course is the plane + -- the back belongs on. Taking it from the shape's bounding + -- box instead put the back out under the ROOF's north + -- overhang, where nothing covers it. + if zMin < b.zLo then b.zLo = zMin end + if b.area > (wallBand and wallBand.area or 0) then + wallBand = b + end + end + end + end + end + end + if wallBand and not (wallBand.width > 1e-6 and wallBand.height > 1e-6 + and wallBand.uHi > wallBand.uLo + and wallBand.vHi > wallBand.vLo) then + wallBand = nil + end + end + self.shapes[#self.shapes + 1] = { mesh = mesh, name = shape.name, index = shape.index, matrixSlots=shape.matrixSlots, bindVertices=shape.matrixSlots and vertices or nil, - facing = facing, wallBand = wallBand, - -- the WALL vertices, for the back wall's silhouette - outline = record.capBack and positions or nil, - tris = record.capBack and tris or nil, - sideXLo = sideXLo, sideXHi = sideXHi, wallZLo = wallZLo, - sidePlanes = sidePlanes, wallZs = wallZs, flatBand = flatBand, - northArea = northArea, southArea = southArea, - northWalls = northWalls, - imagePath = record.capBack and shape.image or nil, - -- The MATERIAL, kept because a texture animation names one. It - -- was being dropped here, which is why nothing could have driven - -- an animation even once the animation was decoded. - material = shape.material, - image = image, - lo = lo, hi = hi } - -- ...AND THE STANDING CARDS AFTER IT, as a shape of their own so they - -- can wear the cut-out copy of the texture while the flat quad keeps - -- the original. - if grassPending then - addGrassCards(self, grassPending.shape, vertices, - grassPending.positions, record.tileUnits) - grassPending = nil - end - else - Logger.warn("gen4 model %s: shape %s would not build (%s)", - tostring(record.name), tostring(shape.name), tostring(mesh)) - end - end - end - - if #self.shapes == 0 then return nil end - if record.capBack then - self.generatedBack = addBackWall(self) or nil - end - if record.groundShadow then - self.generatedShadow = addGroundShadow(self, record.groundShadow) or nil - end - return self -end - --- The highest point of any of this model's shapes, in its own units. --- --- Measured off the geometry rather than read from the header, for the reason --- `framing` gives: the header's bounding box disagrees with the vertices on two --- thirds of this cartridge's models. Cached, because the ground asks once per --- prop per bake and the answer cannot change. -function Gen4Model:topY() - if self.topCache then return self.topCache end - local top = 0 - for _, shape in ipairs(self.shapes or {}) do - if shape.hi and shape.hi[2] and shape.hi[2] > top then top = shape.hi[2] end - end - self.topCache = top - return top -end - --- framing(pose) -> centre, distance --- --- The model's own centre and how far away a camera has to sit to see all of --- it, measured off the geometry IN THE POSE IT WILL BE DRAWN IN. A pose --- matters here: the briefcase's three Poke Balls are 60 units apart only once --- their nodes have placed them, and framing the unposed shapes would put the --- camera close enough to lose two of them off the sides. -function Gen4Model:framing(pose) - pose = pose or self:restPose() - local lo = { math.huge, math.huge, math.huge } - local hi = { -math.huge, -math.huge, -math.huge } - for _, shape in ipairs(self.shapes) do - local m = pose[shape.index] - for corner = 0, 7 do - local p = { - (corner % 2 == 0) and shape.lo[1] or shape.hi[1], - (math.floor(corner / 2) % 2 == 0) and shape.lo[2] or shape.hi[2], - (math.floor(corner / 4) % 2 == 0) and shape.lo[3] or shape.hi[3], - } - if m then - p = { - m[1] * p[1] + m[2] * p[2] + m[3] * p[3] + m[4], - m[5] * p[1] + m[6] * p[2] + m[7] * p[3] + m[8], - m[9] * p[1] + m[10] * p[2] + m[11] * p[3] + m[12], - } - end - for k = 1, 3 do - if p[k] < lo[k] then lo[k] = p[k] end - if p[k] > hi[k] then hi[k] = p[k] end - end - end - end - if lo[1] > hi[1] then return { 0, 0, 0 }, 4 end - local centre = { (lo[1] + hi[1]) / 2, (lo[2] + hi[2]) / 2, (lo[3] + hi[3]) / 2 } - local extent = math.max(hi[1] - lo[1], hi[2] - lo[2], hi[3] - lo[3]) - return centre, math.max(0.001, extent) * 1.8 -end - --- --------------------------------------------------------------------------- --- Drawing --- --------------------------------------------------------------------------- - --- The rest pose: every shape's place with the model's own node transforms. --- Built once and kept, because a model with identity nodes would otherwise --- redo the same walk every frame to arrive at nothing. -function Gen4Model:restPose() - if not self.rest then - local nodes = self.nodes - self.rest = Gen4Nsbmd.pose(self.ops, function(index) - local node = nodes[index + 1] - return node and node.matrix - end) - end - return self.rest -end - --- posed(matrixOf) -> { [shape index] = 4x4 } --- --- The same walk with somebody else's matrices: `matrixOf(node)` returns a --- joint animation's frame, and a node the animation does not cover falls back --- to the model's own. That fallback is not a nicety -- an animation with --- fewer joints than the model has nodes would otherwise collapse the rest to --- the origin, which reads as geometry gone missing rather than as a mismatch. -function Gen4Model:posed(matrixOf) - if not matrixOf then return self:restPose() end - local nodes = self.nodes - return Gen4Nsbmd.pose(self.ops, function(index) - return matrixOf(index) or (nodes[index + 1] and nodes[index + 1].matrix) - end) -end - --- draw(viewProjection, pose) -- into whatever canvas is set. --- --- One matrix send per shape rather than one per model, because each shape --- stands where its node puts it. `pose` is what `restPose` or `posed` --- returned; leaving it out draws the rest pose. --- --- The depth mode is set and RESTORED here rather than left to the caller. --- This is called from inside a screen's draw, and a depth test left switched --- on makes the next ordinary 2D draw vanish or show through in ways that look --- like a bug in the thing that comes after it. --- One of a flipbook's frames, by the path the import stage wrote it under. --- Cached per model, and a path that will not load is remembered as `false` so --- a missing frame is asked for once rather than once a frame. -function Gen4Model:frameImage(path) - if type(path) ~= "string" then return nil end - self.frames = self.frames or {} - if self.frames[path] == nil then - local ok, loaded = pcall(Assets.image, path) - if ok and loaded then - loaded:setFilter("nearest", "nearest") - loaded:setWrap("repeat", "repeat") - self.frames[path] = loaded - else - Logger.warn("gen4 model %s: animation frame %s would not load", - tostring(self.name), tostring(path)) - self.frames[path] = false - end - end - return self.frames[path] or nil -end - --- The identity texture transform, sent for every shape that has no animation --- on it -- which is nearly all of them, nearly all of the time. -local UV_IDENTITY = { 1, 0, 0, 1 } -local UV_NO_SHIFT = { 0, 0 } - --- draw(viewProjection, pose, materials) --- --- `materials` is optional and maps a MATERIAL NAME to what this frame does to --- it: `{ uv = { a, b, c, d, tx, ty }, image = }`. Both halves are --- optional -- a BTA0 supplies the first, a BTP0 the second -- and a material --- with no entry draws exactly as it did before this parameter existed. --- --- `image` is a PATH and the loading is done here, once per path, because the --- caller is a per-frame animation evaluator and asking it to hold LOVE Images --- would put an asset cache in a file that has no business owning one. -local NO_CUT = -1.0e9 - --- A MATERIAL'S POLYGON ALPHA, 0..31, and what to do when the cache predates it. --- --- `polyAttr` bits 16..20 are the DS's per-material alpha and 31 is opaque. A --- building's ground shadow is 9 -- measured off `funsui` in the cartridge, --- where the two ordinary materials are 31 and `h_kage` is 9. Without it every --- shadow drew at full strength, and its texture is a 16x16 of palette index 0 --- which on that material is opaque BLACK, not transparent: *"there are shadows --- for the houses but they're showing as black"*. They were exactly black. -local ALPHA_MAX = 31 - --- THE FALLBACK IS A MEASUREMENT, NOT A GUESS. `h_kage` is the cartridge's one --- shadow texture and it is 9/31 on every material that wears it, so a cache --- written before `alpha` existed can still be drawn correctly by name. It --- costs one string match on 153 of the 590 building models and lets the fix --- land without a re-import; a cache that carries the real value never consults --- it. -local SHADOW_ALPHA = 9 --- EITHER NAME, not the first one that exists. This read `shape.material or --- shape.texture`, which is an or where the question is an either: a material --- and the texture it wears are different strings and on this cartridge only --- one of them tends to say `kage`. --- --- Counted over all 666 land chunks: 17 shadow materials say `kage` in both --- names, 11 say it only in the TEXTURE (`chair4`, `counter2`, `shelf2`, --- `lambert7`, `isu:lambert9` all wear `h_kage` or `m_dun06_kage`) and 2 say it --- only in the MATERIAL (`kage`, with no texture at all). Reading whichever --- came first therefore missed 11 or 2 of the 30 depending on which field the --- caller happened to carry -- and terrain began carrying `material` in the --- same pass as this comment, which would have moved it from the 2 to the 11. -local function shapeAlpha(shape) - local stated = tonumber(shape.alpha) - if stated then return math.min(stated, ALPHA_MAX) / ALPHA_MAX end - local material = tostring(shape.material or ""):lower() - local texture = tostring(shape.texture or ""):lower() - if material:find("kage", 1, true) or texture:find("kage", 1, true) then - return SHADOW_ALPHA / ALPHA_MAX - end - return 1 -end - --- No spread at all, which is what every caller that is not the live ground --- pass wants: a Poke Ball on a menu screen has no camera to be off the centre --- of. SENT ON EVERY DRAW for the reason `yCut` is -- an unset uniform reads --- as zero, and here zero happens to be the right answer, but relying on that --- is how the texture matrix collapsed every model to one texel. -local NO_SPREAD = { 0, 0 } - --- WHICH WAY THE CAMERA IS LOOKING, for the cards to turn to face. --- --- Module state rather than a `draw` argument because every caller that is not --- the free ground pass wants zero, and threading a parameter through all of --- them to say "no" invites the one that forgets. Zero is the identity here, --- so a caller that never touches it renders exactly as it did. `Gen4Ground` --- sets it immediately before its chunk draws and puts it back to zero after. -Gen4Model.billboardYaw = 0 --- A model-z band the height cut leaves unpainted, or nil. Set per chunk by --- the canopy pass; see `Gen4Ground:livePass`. -Gen4Model.zKeep = nil - --- `depthCompare` defaults to "less", which is right for every pass that draws --- a piece of geometry ONCE. --- --- It is a parameter because the canopy pass draws the SAME terrain twice -- --- first with the colour mask off to lay down depth, then again with a height --- cut to paint the part that goes over the sprites -- and the second draw's --- fragments sit at EXACTLY the depth the first one wrote. Under "less" every --- one of them is rejected, so that pass painted nothing at all and the tree --- canopy never went over anybody. "lequal" is what lets a second draw of the --- same surface through. -function Gen4Model:draw(viewProjection, pose, materials, yCut, spread, depthCompare) - local g = love.graphics - if not (shader and viewProjection) then return false end - pose = pose or self:restPose() - local previousShader = g.getShader() - local mode, write = g.getDepthMode() - local culling = g.getMeshCullMode() - -- ...AND THE COLOUR, which is the one piece of shared state this function - -- changed and did not put back. See the restore at the foot of the loop. - local pr, pg, pb, pa = g.getColor() - - g.setShader(shader) - g.setDepthMode(depthCompare or "less", true) - -- BACK FACES ARE NOT CULLED, and that is the cartridge's own choice rather - -- than laziness: a DS polygon carries its own front/back flags in its - -- material, plenty of Gen 4 geometry is single-sided sheets meant to be seen - -- from either side, and culling them uniformly loses the far wall of the - -- briefcase. With a depth buffer the cost of drawing both is a few - -- overdrawn pixels. - g.setMeshCullMode("none") - g.setColor(1, 1, 1, 1) - if shaderCuts then - shader:send("yCut", tonumber(yCut) or NO_CUT) - -- Only meaningful with a cut, and cleared otherwise -- an unset uniform - -- reads as (0, 0), an empty band, but stating it is what keeps a stale - -- band from one pass out of the next. - local band = yCut and Gen4Model.zKeep - shader:send("zKeep", band and { band[1], band[2] } or { 1.0e9, -1.0e9 }) - end - shader:send("heightSpread", spread or NO_SPREAD) - -- SENT ON EVERY DRAW, for the reason `yCut` and `heightSpread` are: an unset - -- uniform reads as zero, and relying on that is how the texture matrix once - -- collapsed every model to a single texel. - shader:send("billboardYaw", tonumber(Gen4Model.billboardYaw) or 0) - for _, shape in ipairs(self.shapes) do - -- Per shape, because a model mixes opaque walls with a translucent shadow - -- and one colour for the whole model would make one of them wrong. - g.setColor(1, 1, 1, shapeAlpha(shape)) + facing = facing, wallBand = wallBand, + -- the WALL vertices, for the back wall's silhouette + outline = record.capBack and positions or nil, + tris = record.capBack and tris or nil, + sideXLo = sideXLo, sideXHi = sideXHi, wallZLo = wallZLo, + sidePlanes = sidePlanes, wallZs = wallZs, flatBand = flatBand, + northArea = northArea, southArea = southArea, + northWalls = northWalls, + imagePath = record.capBack and shape.image or nil, + -- The MATERIAL, kept because a texture animation names one. It + -- was being dropped here, which is why nothing could have driven + -- an animation even once the animation was decoded. + material = shape.material, + image = image, + lo = lo, hi = hi } + -- ...AND THE STANDING CARDS AFTER IT, as a shape of their own so they + -- can wear the cut-out copy of the texture while the flat quad keeps + -- the original. + if grassPending then + addGrassCards(self, grassPending.shape, vertices, + grassPending.positions, record.tileUnits) + grassPending = nil + end + else + Logger.warn("gen4 model %s: shape %s would not build (%s)", + tostring(record.name), tostring(shape.name), tostring(mesh)) + end + end + end + + if #self.shapes == 0 then return nil end + if record.capBack then + self.generatedBack = addBackWall(self) or nil + end + if record.groundShadow then + self.generatedShadow = addGroundShadow(self, record.groundShadow) or nil + end + return self +end + +-- The highest point of any of this model's shapes, in its own units. +-- +-- Measured off the geometry rather than read from the header, for the reason +-- `framing` gives: the header's bounding box disagrees with the vertices on two +-- thirds of this cartridge's models. Cached, because the ground asks once per +-- prop per bake and the answer cannot change. +function Gen4Model:topY() + if self.topCache then return self.topCache end + local top = 0 + for _, shape in ipairs(self.shapes or {}) do + if shape.hi and shape.hi[2] and shape.hi[2] > top then top = shape.hi[2] end + end + self.topCache = top + return top +end + +-- framing(pose) -> centre, distance +-- +-- The model's own centre and how far away a camera has to sit to see all of +-- it, measured off the geometry IN THE POSE IT WILL BE DRAWN IN. A pose +-- matters here: the briefcase's three Poke Balls are 60 units apart only once +-- their nodes have placed them, and framing the unposed shapes would put the +-- camera close enough to lose two of them off the sides. +function Gen4Model:framing(pose) + pose = pose or self:restPose() + local lo = { math.huge, math.huge, math.huge } + local hi = { -math.huge, -math.huge, -math.huge } + for _, shape in ipairs(self.shapes) do + local m = pose[shape.index] + for corner = 0, 7 do + local p = { + (corner % 2 == 0) and shape.lo[1] or shape.hi[1], + (math.floor(corner / 2) % 2 == 0) and shape.lo[2] or shape.hi[2], + (math.floor(corner / 4) % 2 == 0) and shape.lo[3] or shape.hi[3], + } + if m then + p = { + m[1] * p[1] + m[2] * p[2] + m[3] * p[3] + m[4], + m[5] * p[1] + m[6] * p[2] + m[7] * p[3] + m[8], + m[9] * p[1] + m[10] * p[2] + m[11] * p[3] + m[12], + } + end + for k = 1, 3 do + if p[k] < lo[k] then lo[k] = p[k] end + if p[k] > hi[k] then hi[k] = p[k] end + end + end + end + if lo[1] > hi[1] then return { 0, 0, 0 }, 4 end + local centre = { (lo[1] + hi[1]) / 2, (lo[2] + hi[2]) / 2, (lo[3] + hi[3]) / 2 } + local extent = math.max(hi[1] - lo[1], hi[2] - lo[2], hi[3] - lo[3]) + return centre, math.max(0.001, extent) * 1.8 +end + +-- --------------------------------------------------------------------------- +-- Drawing +-- --------------------------------------------------------------------------- + +-- The rest pose: every shape's place with the model's own node transforms. +-- Built once and kept, because a model with identity nodes would otherwise +-- redo the same walk every frame to arrive at nothing. +function Gen4Model:restPose() + if not self.rest then + local nodes = self.nodes + self.rest = Gen4Nsbmd.pose(self.ops, function(index) + local node = nodes[index + 1] + return node and node.matrix + end) + end + return self.rest +end + +-- posed(matrixOf) -> { [shape index] = 4x4 } +-- +-- The same walk with somebody else's matrices: `matrixOf(node)` returns a +-- joint animation's frame, and a node the animation does not cover falls back +-- to the model's own. That fallback is not a nicety -- an animation with +-- fewer joints than the model has nodes would otherwise collapse the rest to +-- the origin, which reads as geometry gone missing rather than as a mismatch. +function Gen4Model:posed(matrixOf) + if not matrixOf then return self:restPose() end + local nodes = self.nodes + return Gen4Nsbmd.pose(self.ops, function(index) + return matrixOf(index) or (nodes[index + 1] and nodes[index + 1].matrix) + end) +end + +-- draw(viewProjection, pose) -- into whatever canvas is set. +-- +-- One matrix send per shape rather than one per model, because each shape +-- stands where its node puts it. `pose` is what `restPose` or `posed` +-- returned; leaving it out draws the rest pose. +-- +-- The depth mode is set and RESTORED here rather than left to the caller. +-- This is called from inside a screen's draw, and a depth test left switched +-- on makes the next ordinary 2D draw vanish or show through in ways that look +-- like a bug in the thing that comes after it. +-- One of a flipbook's frames, by the path the import stage wrote it under. +-- Cached per model, and a path that will not load is remembered as `false` so +-- a missing frame is asked for once rather than once a frame. +function Gen4Model:frameImage(path) + if type(path) ~= "string" then return nil end + self.frames = self.frames or {} + if self.frames[path] == nil then + local ok, loaded = pcall(Assets.image, path) + if ok and loaded then + loaded:setFilter("nearest", "nearest") + loaded:setWrap("repeat", "repeat") + self.frames[path] = loaded + else + Logger.warn("gen4 model %s: animation frame %s would not load", + tostring(self.name), tostring(path)) + self.frames[path] = false + end + end + return self.frames[path] or nil +end + +-- The identity texture transform, sent for every shape that has no animation +-- on it -- which is nearly all of them, nearly all of the time. +local UV_IDENTITY = { 1, 0, 0, 1 } +local UV_NO_SHIFT = { 0, 0 } + +-- draw(viewProjection, pose, materials) +-- +-- `materials` is optional and maps a MATERIAL NAME to what this frame does to +-- it: `{ uv = { a, b, c, d, tx, ty }, image = }`. Both halves are +-- optional -- a BTA0 supplies the first, a BTP0 the second -- and a material +-- with no entry draws exactly as it did before this parameter existed. +-- +-- `image` is a PATH and the loading is done here, once per path, because the +-- caller is a per-frame animation evaluator and asking it to hold LOVE Images +-- would put an asset cache in a file that has no business owning one. +local NO_CUT = -1.0e9 + +-- A MATERIAL'S POLYGON ALPHA, 0..31, and what to do when the cache predates it. +-- +-- `polyAttr` bits 16..20 are the DS's per-material alpha and 31 is opaque. A +-- building's ground shadow is 9 -- measured off `funsui` in the cartridge, +-- where the two ordinary materials are 31 and `h_kage` is 9. Without it every +-- shadow drew at full strength, and its texture is a 16x16 of palette index 0 +-- which on that material is opaque BLACK, not transparent: *"there are shadows +-- for the houses but they're showing as black"*. They were exactly black. +local ALPHA_MAX = 31 + +-- THE FALLBACK IS A MEASUREMENT, NOT A GUESS. `h_kage` is the cartridge's one +-- shadow texture and it is 9/31 on every material that wears it, so a cache +-- written before `alpha` existed can still be drawn correctly by name. It +-- costs one string match on 153 of the 590 building models and lets the fix +-- land without a re-import; a cache that carries the real value never consults +-- it. +local SHADOW_ALPHA = 9 +-- EITHER NAME, not the first one that exists. This read `shape.material or +-- shape.texture`, which is an or where the question is an either: a material +-- and the texture it wears are different strings and on this cartridge only +-- one of them tends to say `kage`. +-- +-- Counted over all 666 land chunks: 17 shadow materials say `kage` in both +-- names, 11 say it only in the TEXTURE (`chair4`, `counter2`, `shelf2`, +-- `lambert7`, `isu:lambert9` all wear `h_kage` or `m_dun06_kage`) and 2 say it +-- only in the MATERIAL (`kage`, with no texture at all). Reading whichever +-- came first therefore missed 11 or 2 of the 30 depending on which field the +-- caller happened to carry -- and terrain began carrying `material` in the +-- same pass as this comment, which would have moved it from the 2 to the 11. +local function shapeAlpha(shape) + local stated = tonumber(shape.alpha) + if stated then return math.min(stated, ALPHA_MAX) / ALPHA_MAX end + local material = tostring(shape.material or ""):lower() + local texture = tostring(shape.texture or ""):lower() + if material:find("kage", 1, true) or texture:find("kage", 1, true) then + return SHADOW_ALPHA / ALPHA_MAX + end + return 1 +end + +-- No spread at all, which is what every caller that is not the live ground +-- pass wants: a Poke Ball on a menu screen has no camera to be off the centre +-- of. SENT ON EVERY DRAW for the reason `yCut` is -- an unset uniform reads +-- as zero, and here zero happens to be the right answer, but relying on that +-- is how the texture matrix collapsed every model to one texel. +local NO_SPREAD = { 0, 0 } + +-- WHICH WAY THE CAMERA IS LOOKING, for the cards to turn to face. +-- +-- Module state rather than a `draw` argument because every caller that is not +-- the free ground pass wants zero, and threading a parameter through all of +-- them to say "no" invites the one that forgets. Zero is the identity here, +-- so a caller that never touches it renders exactly as it did. `Gen4Ground` +-- sets it immediately before its chunk draws and puts it back to zero after. +Gen4Model.billboardYaw = 0 +-- A model-z band the height cut leaves unpainted, or nil. Set per chunk by +-- the canopy pass; see `Gen4Ground:livePass`. +Gen4Model.zKeep = nil + +-- `depthCompare` defaults to "less", which is right for every pass that draws +-- a piece of geometry ONCE. +-- +-- It is a parameter because the canopy pass draws the SAME terrain twice -- +-- first with the colour mask off to lay down depth, then again with a height +-- cut to paint the part that goes over the sprites -- and the second draw's +-- fragments sit at EXACTLY the depth the first one wrote. Under "less" every +-- one of them is rejected, so that pass painted nothing at all and the tree +-- canopy never went over anybody. "lequal" is what lets a second draw of the +-- same surface through. +function Gen4Model:draw(viewProjection, pose, materials, yCut, spread, depthCompare) + local g = love.graphics + if not (shader and viewProjection) then return false end + pose = pose or self:restPose() + local previousShader = g.getShader() + local mode, write = g.getDepthMode() + local culling = g.getMeshCullMode() + -- ...AND THE COLOUR, which is the one piece of shared state this function + -- changed and did not put back. See the restore at the foot of the loop. + local pr, pg, pb, pa = g.getColor() + + g.setShader(shader) + g.setDepthMode(depthCompare or "less", true) + -- BACK FACES ARE NOT CULLED, and that is the cartridge's own choice rather + -- than laziness: a DS polygon carries its own front/back flags in its + -- material, plenty of Gen 4 geometry is single-sided sheets meant to be seen + -- from either side, and culling them uniformly loses the far wall of the + -- briefcase. With a depth buffer the cost of drawing both is a few + -- overdrawn pixels. + g.setMeshCullMode("none") + g.setColor(1, 1, 1, 1) + if shaderCuts then + shader:send("yCut", tonumber(yCut) or NO_CUT) + -- Only meaningful with a cut, and cleared otherwise -- an unset uniform + -- reads as (0, 0), an empty band, but stating it is what keeps a stale + -- band from one pass out of the next. + local band = yCut and Gen4Model.zKeep + shader:send("zKeep", band and { band[1], band[2] } or { 1.0e9, -1.0e9 }) + end + shader:send("heightSpread", spread or NO_SPREAD) + -- SENT ON EVERY DRAW, for the reason `yCut` and `heightSpread` are: an unset + -- uniform reads as zero, and relying on that is how the texture matrix once + -- collapsed every model to a single texel. + shader:send("billboardYaw", tonumber(Gen4Model.billboardYaw) or 0) + for _, shape in ipairs(self.shapes) do + -- Per shape, because a model mixes opaque walls with a translucent shadow + -- and one colour for the whole model would make one of them wrong. + g.setColor(1, 1, 1, shapeAlpha(shape)) local place = pose[shape.index] if shape.matrixSlots and pose.stack then -- A display list can restore a different joint for every vertex. @@ -2553,129 +2567,129 @@ function Gen4Model:draw(viewProjection, pose, materials, yCut, spread, depthComp end place=nil end - shader:send("mvp", place and multiply(viewProjection, place) or viewProjection) - - local state = materials and shape.material and materials[shape.material] - local uv = state and state.uv - if uv then - shader:send("uvRotScale", { uv[1], uv[2], uv[3], uv[4] }) - shader:send("uvTranslate", { uv[5] or 0, uv[6] or 0 }) - else - shader:send("uvRotScale", UV_IDENTITY) - shader:send("uvTranslate", UV_NO_SHIFT) - end - - -- A BTP0 swaps which picture the material wears. Swapped back afterwards - -- rather than left: the mesh is shared with the baked copy of this model, - -- and a texture left on it is a door stuck open everywhere else it appears. - local swapped = state and state.image and self:frameImage(state.image) - if swapped then shape.mesh:setTexture(swapped) end - g.draw(shape.mesh) - if swapped then - if shape.image then shape.mesh:setTexture(shape.image) - else shape.mesh:setTexture() end - end - end - - g.setMeshCullMode(culling) - g.setDepthMode(mode, write) - g.setShader(previousShader) - -- THE COLOUR GOES BACK TOO, AND NOT PUTTING IT BACK MADE EVERY CHARACTER - -- SEE-THROUGH. - -- - -- Reported from play, repeatedly: *"the npc and player sprites are still see - -- through specifically with the 3d camera on in tilted views and first and - -- third person"*, and before that as Rowan alone. - -- - -- The loop above sets `setColor(1, 1, 1, shapeAlpha(shape))` PER SHAPE, which - -- is right -- a model mixes opaque walls with a translucent shadow. But the - -- colour is global graphics state, so whatever the LAST shape asked for was - -- still in force when this returned. `SpriteRenderer` sets no colour of its - -- own; it inherits. So a character drawn after a chunk whose final shape was - -- translucent was drawn AT THAT SHAPE'S ALPHA. - -- - -- Indoors is where it showed worst because indoors is where those shapes are: - -- `shade`, `stair_d01_shade`, `table01_1_shade` and `dun_shadow` all state - -- 12/31, so a character came out at about 39% opacity -- which is exactly - -- "see through" and not a depth or sorting fault at all. - -- - -- THE OTHER THREE RESTORES WERE ALREADY HERE, which is what makes this an - -- omission rather than a design: the function knew it was borrowing shared - -- state and put back the cull mode, the depth mode and the shader. The - -- `setColor(1, 1, 1, 1)` at the top of the loop is the same knowledge, said - -- once at the wrong end. - -- - -- AND IT PREDATES THE TERRAIN ALPHA WORK but was made much wider by it: the - -- `kage` name fallback already gave 9/31 to kage-named shapes, so this bit - -- wherever a model happened to end on one -- Rowan's room. Carrying the - -- cartridge's real alpha for 155 materials turned a few rooms into most of - -- them. - g.setColor(pr, pg, pb, pa) - return true -end - --- A canvas that can hold a depth buffer, which an ordinary one cannot. --- --- Returned as a pair because LOVE wants them handed back together --- (`setCanvas { colour, depthstencil = depth }`), and a caller that kept only --- the colour one would get a scene with no depth test and no error. --- THE DEPTH FORMATS TO TRY, best first. --- --- `depth24` alone is not a safe ask. It is the common desktop format and the --- one to prefer, but plenty of drivers -- Intel integrated parts and anything --- going through ANGLE especially -- expose only the packed depth+stencil --- combination, and a few mobile-derived ones only ever offer `depth16`. A --- single ask meant one unsupported format turned the WHOLE 3D path off and --- left a flat world that looked like a bug in the camera rather than a --- capability that was never there. -local DEPTH_FORMATS = { "depth24", "depth24stencil8", "depth32f", "depth16" } - --- Worked out once: `newCanvas` is not free to call speculatively, and the --- answer cannot change while the game is running. -local depthFormat, depthChecked = nil, false - -local function chooseDepthFormat() - if depthChecked then return depthFormat end - depthChecked = true - -- ASK BEFORE TRYING. `getCanvasFormats` is the driver's own list, so a - -- format it does not name is one no amount of retrying will produce. - local ok, supported = pcall(love.graphics.getCanvasFormats) - for _, format in ipairs(DEPTH_FORMATS) do - if not ok or supported == nil or supported[format] then - local made, canvas = pcall(love.graphics.newCanvas, 8, 8, - { format = format, readable = false }) - if made and canvas then - depthFormat = format - if canvas.release then canvas:release() end - Logger.info("gen4 model: depth buffer format %s", format) - return depthFormat - end - end - end - Logger.warn("gen4 model: no depth canvas format available (tried %s); " - .. "3D models cannot be drawn on this device", - table.concat(DEPTH_FORMATS, ", ")) - return nil -end - -function Gen4Model.newTarget(width, height) - local format = chooseDepthFormat() - if not format then return nil end - local okColour, colour = pcall(love.graphics.newCanvas, width, height) - if not okColour or not colour then return nil end - local okDepth, depth = pcall(love.graphics.newCanvas, width, height, - { format = format, readable = false }) - if not okDepth or not depth then - -- Without a depth buffer the model would draw in submission order, which - -- on a solid object means the back of it in front. Better to draw nothing - -- and say why. - Logger.warn("gen4 model: %s canvas of %dx%d failed (%s); " - .. "3D models cannot be drawn at this size", - format, width, height, tostring(depth)) - return nil - end - colour:setFilter("nearest", "nearest") - return colour, depth -end - -return Gen4Model + shader:send("mvp", place and multiply(viewProjection, place) or viewProjection) + + local state = materials and shape.material and materials[shape.material] + local uv = state and state.uv + if uv then + shader:send("uvRotScale", { uv[1], uv[2], uv[3], uv[4] }) + shader:send("uvTranslate", { uv[5] or 0, uv[6] or 0 }) + else + shader:send("uvRotScale", UV_IDENTITY) + shader:send("uvTranslate", UV_NO_SHIFT) + end + + -- A BTP0 swaps which picture the material wears. Swapped back afterwards + -- rather than left: the mesh is shared with the baked copy of this model, + -- and a texture left on it is a door stuck open everywhere else it appears. + local swapped = state and state.image and self:frameImage(state.image) + if swapped then shape.mesh:setTexture(swapped) end + g.draw(shape.mesh) + if swapped then + if shape.image then shape.mesh:setTexture(shape.image) + else shape.mesh:setTexture() end + end + end + + g.setMeshCullMode(culling) + g.setDepthMode(mode, write) + g.setShader(previousShader) + -- THE COLOUR GOES BACK TOO, AND NOT PUTTING IT BACK MADE EVERY CHARACTER + -- SEE-THROUGH. + -- + -- Reported from play, repeatedly: *"the npc and player sprites are still see + -- through specifically with the 3d camera on in tilted views and first and + -- third person"*, and before that as Rowan alone. + -- + -- The loop above sets `setColor(1, 1, 1, shapeAlpha(shape))` PER SHAPE, which + -- is right -- a model mixes opaque walls with a translucent shadow. But the + -- colour is global graphics state, so whatever the LAST shape asked for was + -- still in force when this returned. `SpriteRenderer` sets no colour of its + -- own; it inherits. So a character drawn after a chunk whose final shape was + -- translucent was drawn AT THAT SHAPE'S ALPHA. + -- + -- Indoors is where it showed worst because indoors is where those shapes are: + -- `shade`, `stair_d01_shade`, `table01_1_shade` and `dun_shadow` all state + -- 12/31, so a character came out at about 39% opacity -- which is exactly + -- "see through" and not a depth or sorting fault at all. + -- + -- THE OTHER THREE RESTORES WERE ALREADY HERE, which is what makes this an + -- omission rather than a design: the function knew it was borrowing shared + -- state and put back the cull mode, the depth mode and the shader. The + -- `setColor(1, 1, 1, 1)` at the top of the loop is the same knowledge, said + -- once at the wrong end. + -- + -- AND IT PREDATES THE TERRAIN ALPHA WORK but was made much wider by it: the + -- `kage` name fallback already gave 9/31 to kage-named shapes, so this bit + -- wherever a model happened to end on one -- Rowan's room. Carrying the + -- cartridge's real alpha for 155 materials turned a few rooms into most of + -- them. + g.setColor(pr, pg, pb, pa) + return true +end + +-- A canvas that can hold a depth buffer, which an ordinary one cannot. +-- +-- Returned as a pair because LOVE wants them handed back together +-- (`setCanvas { colour, depthstencil = depth }`), and a caller that kept only +-- the colour one would get a scene with no depth test and no error. +-- THE DEPTH FORMATS TO TRY, best first. +-- +-- `depth24` alone is not a safe ask. It is the common desktop format and the +-- one to prefer, but plenty of drivers -- Intel integrated parts and anything +-- going through ANGLE especially -- expose only the packed depth+stencil +-- combination, and a few mobile-derived ones only ever offer `depth16`. A +-- single ask meant one unsupported format turned the WHOLE 3D path off and +-- left a flat world that looked like a bug in the camera rather than a +-- capability that was never there. +local DEPTH_FORMATS = { "depth24", "depth24stencil8", "depth32f", "depth16" } + +-- Worked out once: `newCanvas` is not free to call speculatively, and the +-- answer cannot change while the game is running. +local depthFormat, depthChecked = nil, false + +local function chooseDepthFormat() + if depthChecked then return depthFormat end + depthChecked = true + -- ASK BEFORE TRYING. `getCanvasFormats` is the driver's own list, so a + -- format it does not name is one no amount of retrying will produce. + local ok, supported = pcall(love.graphics.getCanvasFormats) + for _, format in ipairs(DEPTH_FORMATS) do + if not ok or supported == nil or supported[format] then + local made, canvas = pcall(love.graphics.newCanvas, 8, 8, + { format = format, readable = false }) + if made and canvas then + depthFormat = format + if canvas.release then canvas:release() end + Logger.info("gen4 model: depth buffer format %s", format) + return depthFormat + end + end + end + Logger.warn("gen4 model: no depth canvas format available (tried %s); " + .. "3D models cannot be drawn on this device", + table.concat(DEPTH_FORMATS, ", ")) + return nil +end + +function Gen4Model.newTarget(width, height) + local format = chooseDepthFormat() + if not format then return nil end + local okColour, colour = pcall(love.graphics.newCanvas, width, height) + if not okColour or not colour then return nil end + local okDepth, depth = pcall(love.graphics.newCanvas, width, height, + { format = format, readable = false }) + if not okDepth or not depth then + -- Without a depth buffer the model would draw in submission order, which + -- on a solid object means the back of it in front. Better to draw nothing + -- and say why. + Logger.warn("gen4 model: %s canvas of %dx%d failed (%s); " + .. "3D models cannot be drawn at this size", + format, width, height, tostring(depth)) + return nil + end + colour:setFilter("nearest", "nearest") + return colour, depth +end + +return Gen4Model diff --git a/src/render/Gen4View.lua b/src/render/Gen4View.lua index 161a84b9a..09377ad68 100644 --- a/src/render/Gen4View.lua +++ b/src/render/Gen4View.lua @@ -412,6 +412,9 @@ function Gen4View:follow(x, z, facing, baseY) self.x = x - sin(f) * horizontal self.z = z + cos(f) * horizontal self.y = baseY + pivotY + sin(e) * radius + -- what the eye looks at, kept so the ground can pull the eye in along + -- this line when a wall stands on it (Gen4Ground:clearCameraLine) + self.pivotX, self.pivotY, self.pivotZ = x, baseY + pivotY, z -- LOOK AT THE PIVOT, which is what puts the player in the middle. -- -- Reported from play: *"players not centered on the screen in third diff --git a/src/script/Gen4Commands.lua b/src/script/Gen4Commands.lua index 168095c6c..0b4228483 100644 --- a/src/script/Gen4Commands.lua +++ b/src/script/Gen4Commands.lua @@ -414,6 +414,14 @@ function Commands.g4_buffer(ctx, slot, kind, value) local def = data and data.items and (data.items[id] or data.items[("ITEM_%03d"):format(id)]) text = def and def.name + elseif kind == "partyspecies" then + -- ScrCmd_BufferPartyMonSpecies (scrcmd_strings.c): a PARTY SLOT, and the + -- species name of whoever stands in it -- read as a species id, slot 0 + -- named nothing and the lab's "received -----" was the result + local slotIndex = valueOf(ctx, value) + local mon = save and save.party and save.party[(tonumber(slotIndex) or 0) + 1] + local def = mon and data and data.pokemon and data.pokemon[mon.species] + text = def and def.name elseif kind == "nickname" then local slotIndex = valueOf(ctx, value) local mon = save and save.party and save.party[slotIndex + 1] @@ -458,8 +466,17 @@ function Commands.g4_buffer(ctx, slot, kind, value) local list = menus and menus.bag and menus.bag.pockets text = list and list[n + 1] if not text then - local label = require("src.import.Gen4Text").label(395, n) + -- HeartGold / SoulSilver read msg_0226 (message_format.c), whose names + -- carry the colour code and pocket glyph in front, as 396 does here + local hgss = require("src.import.Gen4ScriptBands").family == "hgss" + local label = require("src.import.Gen4Text").label(hgss and 226 or 395, n) text = data and data.text and data.text[label] + if hgss and text then text = text:gsub("^{COLOR %d+}.-{COLOR 0}", "") end + -- Black / White: system bank 55, ITEMS .. KEY ITEMS in the pocket order + -- 0xBB answers (Gen5Data's bwPocket) + if require("src.import.Gen4ScriptBands").family == "bw" then + text = data and data.text and data.text[("TEXT_G5S_0055_%05d"):format(n)] + end end elseif kind == "itemPlural" then -- `bufferitemnameplural` reads a BANK OF ITS OWN (394), not the singular @@ -987,6 +1004,24 @@ end Commands.meta.g4_choose_message_word = { foreground = true, blocking = true } -- `messagevar` takes its entry out of a var rather than out of the row. +-- GetStdMsgNaix (HeartGold / SoulSilver): which shared bank a script prints +-- from next with MsgBoxExtern -- scrcmd_message.c's ov01_022067C8, the +-- day-of-week siblings 752, field moves 211, Cameron the photographer 30 and +-- the shops 435; an index past the table writes 0. +Commands.HGSS_STD_MSG_BANKS = { [0] = 752, 211, 30, 435 } +function Commands.g4_std_msg_bank(ctx, index, destVar) + setVar(ctx.save, destVar, Commands.HGSS_STD_MSG_BANKS[valueOf(ctx, index)] or 0) +end + +-- GenderMsgBox (HeartGold / SoulSilver): the male entry for a boy, the other +-- for anyone else, from the block's bank when it has one. +function Commands.g4_gender_message(ctx, male, female, bank) + local save = ctx.save + local entry = (save and save.player and save.player.gender == "girl") and female or male + if bank then return Commands.g4_message_bank(ctx, bank, entry) end + return Commands.g4_message(ctx, entry) +end + function Commands.g4_message_var(ctx, id, bank) if bank then return Commands.g4_message_bank(ctx, bank, getVar(ctx.save, id)) end return Commands.g4_message(ctx, getVar(ctx.save, id)) @@ -2509,6 +2544,818 @@ function Commands.g4_catching_tutorial(ctx) end Commands.meta.g4_catching_tutorial = { foreground = true, blocking = true } +-- HEARTGOLD / SOULSILVER'S DEMONSTRATION, Route 29: BattleSetup_New_Tutorial +-- (battle_setup.c). The player's side is the friend -- the name is bank 445 +-- entry `gender ^ 1`, "Lyra" for a boy -- with a level 5 Marill and twenty +-- Poke Balls, against a wild level 2 Rattata. +function Commands.g4_hgss_catching_tutorial(ctx) + local game = ctx.game + local save = {} + for k, v in pairs(game.save) do save[k] = v end + local girl = game.save.player and game.save.player.gender == "girl" + local text = game.data and game.data.text or {} + local name = text[("TEXT_B0445_%05d"):format(girl and 0 or 1)] or (girl and "Ethan" or "Lyra") + save.player = { name = name, gender = girl and "boy" or "girl" } + save.party = { require("src.pokemon.Pokemon").new(game.data, 183, 5) } + save.pokedex = { seen = {}, owned = {} } + save.inventory = { [4] = 20 } + local demoGame = setmetatable({ save = save }, { __index = game }) + local battle = require("src.battle.BattleState").newWild(demoGame, 19, 2) + battle:makeDudeDemo(save.player.name) + battle.demoBallCount = "x20" + battle.onFinish = function() + if ctx.runner then ctx.runner:resume() end + end + if ctx.overworld and ctx.overworld.pushBattle then ctx.overworld:pushBattle(battle) + else game.stack:push(battle) end + if ctx.runner then ctx.runner:yield() end +end +Commands.meta.g4_hgss_catching_tutorial = { foreground = true, blocking = true } + +-- BLACK / WHITE'S DEMONSTRATION, Route 1 (script 0x17D, its routine at +-- 0x0216E95C in Black): the player's side a level 7 Minccino (474 + 0x62) +-- knowing Pound and Growl, against a wild level 2 Patrat (474 + 0x1E) +-- knowing Tackle and Leer -- every number read off the routine. The name on +-- Juniper's side is RECONSTRUCTED: no name bank carries hers on its own. +function Commands.g4_bw_catching_tutorial(ctx) + local game = ctx.game + local Pokemon = require("src.pokemon.Pokemon") + local function withMoves(mon, list) + mon.moves = {} + for _, id in ipairs(list) do + local m = game.data.moves and game.data.moves[id] + mon.moves[#mon.moves + 1] = { id = id, pp = m and m.pp or 0 } + end + return mon + end + local save = {} + for k, v in pairs(game.save) do save[k] = v end + save.player = { name = "Juniper", gender = "girl" } + save.party = { withMoves(Pokemon.new(game.data, 572, 7), { 1, 45 }) } + save.pokedex = { seen = {}, owned = {} } + save.inventory = { [4] = 20 } + local demoGame = setmetatable({ save = save }, { __index = game }) + local battle = require("src.battle.BattleState").newWild(demoGame, 504, 2) + if battle.enemy and battle.enemy.mon then withMoves(battle.enemy.mon, { 33, 43 }) end + battle:makeDudeDemo(save.player.name) + battle.demoBallCount = "x20" + battle.onFinish = function() + if ctx.runner then ctx.runner:resume() end + end + if ctx.overworld and ctx.overworld.pushBattle then ctx.overworld:pushBattle(battle) + else game.stack:push(battle) end + if ctx.runner then ctx.runner:yield() end +end +Commands.meta.g4_bw_catching_tutorial = { foreground = true, blocking = true } + +-- HEARTGOLD / SOULSILVER: ScrCmd_NameRival -> CallTask_NamingScreen( +-- NAME_SCREEN_RIVAL, maxLen PLAYER_NAME_LENGTH = 7) and the result var is the +-- screen's `noInput`. The title is the naming bank's own (249 entry 3, "This +-- boy's name?"), and the name is kept where every reader of the rival looks. +function Commands.g4_name_rival(ctx, destVar) + setVar(ctx.save, destVar, 1) + if not (ctx.game and ctx.game.stack) then return end + local T = require("src.import.Gen4Text") + local title = ctx.game.data and ctx.game.data.text and ctx.game.data.text[T.label(249, 3)] + title = type(title) == "string" and title:gsub("%s*{YESNO %d+}", "") or "This boy's name?" + local runner = ctx.runner + require("src.ui.Screens").push(ctx.game, "NamingScreen", { + kind = "rival", maxLen = 7, title = title, + onDone = function(typed) + local name = tostring(typed or "") + if name ~= "" and ctx.save then + ctx.save.player = ctx.save.player or {} + ctx.save.player.rival = name + setVar(ctx.save, destVar, 0) + end + if runner then runner:resume() end + end, + }) + if runner then runner:yield() end +end +Commands.meta.g4_name_rival = { foreground = true, blocking = true } + +-- THE POKEGEAR (scrcmd_c.c): RegisterPokegearCard <0..2> (anything else is +-- card 0), RegisterGearNumber below NUM_PHONE_CONTACTS (75), +-- CheckRegisteredPhoneNumber , and ScrCmd_804 the town map's +-- unlock level. Kept in `save.hgssPokegear`; nothing draws it yet. +local function pokegear(save) + if not save then return nil end + save.hgssPokegear = save.hgssPokegear or { cards = {}, numbers = {}, mapLevel = 0 } + return save.hgssPokegear +end +function Commands.g4_pokegear_card(ctx, card) + local pg = pokegear(ctx.save); if not pg then return end + local c = math.floor(tonumber(card) or 0) + if c ~= 1 and c ~= 2 then c = 0 end + pg.cards[c] = true +end +function Commands.g4_pokegear_number(ctx, number) + local pg = pokegear(ctx.save); if not pg then return end + local n = math.floor(valueOf(ctx, number) or 0) % 256 + if n < 75 then pg.numbers[n] = true end +end +function Commands.g4_pokegear_has_number(ctx, number, destVar) + local pg = pokegear(ctx.save) + local n = math.floor(valueOf(ctx, number) or 0) % 256 + local has = pg and n < 75 and pg.numbers[n] and 1 or 0 + setVar(ctx.save, destVar, has) + setResult(ctx, has) +end +function Commands.g4_pokegear_map_level(ctx, level) + local pg = pokegear(ctx.save); if not pg then return end + pg.mapLevel = math.floor(tonumber(level) or 0) +end + +-- MOM'S SAVINGS: CheckBankBalance -> balance >= amount; +-- BankOrWalletIsFull <0 bank / 1 wallet> -> at MAX_MONEY (999,999). +-- BankTransaction <0 deposit / 1 withdraw> opens the amount dial and +-- answers 0 done / 1 cancelled; THE DIAL IS NOT BUILT, so it answers +-- cancelled -- the script's own "come back any time" path -- and says so. +Gen4Commands.MAX_MONEY = 999999 +function Commands.g4_bank_balance_at_least(ctx, destVar, amount) + local balance = tonumber(ctx.save and ctx.save.hgssMomSavings) or 0 + local yes = balance >= (tonumber(amount) or 0) and 1 or 0 + setVar(ctx.save, destVar, yes) + setResult(ctx, yes) +end +function Commands.g4_bank_or_wallet_full(ctx, which, destVar) + local amount = (tonumber(which) == 0) and (tonumber(ctx.save and ctx.save.hgssMomSavings) or 0) + or (tonumber(ctx.save and ctx.save.money) or 0) + local full = amount >= Gen4Commands.MAX_MONEY and 1 or 0 + setVar(ctx.save, destVar, full) + setResult(ctx, full) +end +-- ScrCmd_GetPersonCoords: the object's X and Z -- MapObject_GetXCoord, the +-- same absolute matrix coordinates GetPlayerCoords answers in. An object +-- not on the map answers 255, 255, as the cartridge does. +function Commands.g4_object_coords(ctx, id, xVar, zVar) + local who = objectById(ctx, math.floor(valueOf(ctx, id) or 0)) + if not who then + setVar(ctx.save, xVar, 255) + setVar(ctx.save, zVar, 255) + return + end + local x, z = toMatrix(ctx, who.cellX, who.cellY) + setVar(ctx.save, xVar, x) + setVar(ctx.save, zVar, z) +end + +-- THE PHOTO ALBUM (HGSS): Cameron's photos in `save.hgssPhotoAlbum`, each +-- the photo number and the map it was taken on; the album screen is not built +Gen4Commands.PHOTO_ALBUM_MAX = 36 +local function photoAlbum(save) + if not save then return nil end + save.hgssPhotoAlbum = save.hgssPhotoAlbum or {} + return save.hgssPhotoAlbum +end +function Commands.g4_take_photo(ctx, number) + local album = photoAlbum(ctx.save) + if not album or #album >= Gen4Commands.PHOTO_ALBUM_MAX then return end + local ow = ctx.overworld + album[#album + 1] = { photo = math.floor(tonumber(number) or 0), + map = ow and ow.map and ow.map.id or nil } +end +function Commands.g4_count_photos(ctx, destVar) + local album = photoAlbum(ctx.save) + setVar(ctx.save, destVar, album and #album or 0) +end +function Commands.g4_photo_album_full(ctx, destVar) + local album = photoAlbum(ctx.save) + local full = (album and #album >= Gen4Commands.PHOTO_ALBUM_MAX) and 1 or 0 + setVar(ctx.save, destVar, full) + setResult(ctx, full) +end + +-- THE VIOLET GYM LIFT (field/gymmick/violet.c, gymmick_init.c). Build model +-- 111 stands at (0xF8, y, 0x148) -- tile (15.5, 20.5) -- over the 3 x 3 tiles +-- (14..16, 19..21) whose height the dynamic terrain manager pins to the +-- platform's: 32 down, 496 up, 16 a frame between. The state is the save's +-- gymmick (`save.hgssGymmick.violetLift`, 0 down / 1 up); InitVioletGym puts +-- it down on every entry, GymmickInit_Violet then places it. +Gen4Commands.VIOLET_LIFT = { model = 111, x = 0xF8, z = 0x148, down = 32, up = 496, speed = 16, + tiles = { x = 14, z = 19, w = 3, h = 3 } } +local function violetLiftAt(ctx, y) + local ow = ctx.overworld + local def = ow and ow.map and ow.map.def + if not def then return end + local L = Gen4Commands.VIOLET_LIFT + -- a fresh list each time, so Gen4Ground re-reads it + def.gen4RuntimeProps = { { model = L.model, x = L.x, y = y, z = L.z } } + def.gen4HeightEdits = def.gen4HeightEdits or {} + for dx = 0, L.tiles.w - 1 do + for dz = 0, L.tiles.h - 1 do + def.gen4HeightEdits[(L.tiles.x + dx) .. "," .. (L.tiles.z + dz)] = y + end + end +end +function Commands.g4_violet_gym_init(ctx) + if not ctx.save then return end + ctx.save.hgssGymmick = { kind = "violet", violetLift = 0 } + violetLiftAt(ctx, Gen4Commands.VIOLET_LIFT.down) + Logger.info("gen4 violet gym: lift placed down (model %d at y %d)", Gen4Commands.VIOLET_LIFT.model, + Gen4Commands.VIOLET_LIFT.down) +end +function Commands.g4_violet_gym_elevator(ctx) + local L = Gen4Commands.VIOLET_LIFT + local g = ctx.save and ctx.save.hgssGymmick + if not (g and g.kind == "violet") then + Commands.g4_violet_gym_init(ctx) + g = ctx.save and ctx.save.hgssGymmick + if not g then return end + end + local going = (g.violetLift == 0) and 1 or 0 + local y = (g.violetLift == 0) and L.down or L.up + local target = (going == 1) and L.up or L.down + local step = (going == 1) and L.speed or -L.speed + g.violetLift = going + local runner = ctx.runner + if not runner then violetLiftAt(ctx, target) return end + runner.waitingCheck = function() + y = y + step + if (step > 0 and y >= target) or (step < 0 and y <= target) then y = target end + violetLiftAt(ctx, y) + return y == target + end + runner:yield() +end +Commands.meta.g4_violet_gym_elevator = { blocking = true } + +-- BUGSY'S GYM (field/gymmick/azalea.c): four Spinarak carts on twelve +-- stations, two switches choosing their routes. The tables and the cart's +-- motion are src/world/HgssAzaleaGym; the state is the save's gymmick, as +-- the cartridge keeps it (`spiders`, `switches`). +local function azaleaState(ctx) + local Azalea = require("src.world.HgssAzaleaGym") + local g = ctx.save and ctx.save.hgssGymmick + if not (g and g.kind == "azalea") then + g = Azalea.initialState() + if ctx.save then ctx.save.hgssGymmick = g end + end + return g, Azalea +end +local function azaleaPlace(ctx, override) + local ow = ctx.overworld + local def = ow and ow.map and ow.map.def + if not def then return end + local g, Azalea = azaleaState(ctx) + def.gen4RuntimeProps = Azalea.props(g, override) +end +-- InitAzaleaGym (the gym's init script) resets the save state; the carts +-- are then placed where it says, as GymmickInit_Azalea does on every load +function Commands.g4_azalea_gym_init(ctx) + if not ctx.save then return end + ctx.save.hgssGymmick = require("src.world.HgssAzaleaGym").initialState() + azaleaPlace(ctx) + Logger.info("gen4 azalea gym: carts at stations 0, 1, 2, 7") +end +function Commands.g4_azalea_gym_switch(ctx, which) + local g, Azalea = azaleaState(ctx) + Azalea.flip(g, math.floor(valueOf(ctx, which) or 0)) + azaleaPlace(ctx) + Logger.info("gen4 azalea gym: switch %s -> state %d", tostring(which), g.switches) +end +-- BeginAzaleaGymSpinarakRide: step onto the cart (up, or down for the +-- stations whose route runs backwards), 4 frames, the ride, then the player +-- is put on the last node's tile, 16 frames (the cartridge's shake and +-- wait), and steps off the same way. +local AZALEA_STEP_FRAMES = 8 +function Commands.g4_azalea_gym_spinarak(ctx, station) + local g, Azalea = azaleaState(ctx) + local n = math.floor(valueOf(ctx, station) or 0) + local ride = Azalea.ride(g, n) + local ow = ctx.overworld + local p = ow and ow.player + if not ride then + Logger.warn("gen4 azalea gym: no cart at station %d (switches %d)", n, g.switches) + return + end + local dz = (ride.stepDir == "down") and 1 or -1 + local function finish() + g.spiders[ride.spider] = ride.destination + azaleaPlace(ctx) + end + if not (p and ctx.runner) then + finish() + return + end + local phase, frame = "on", 0 + local startPy = p.py + p.facing = ride.stepDir + ctx.runner.waitingCheck = function() + frame = frame + 1 + if phase == "on" or phase == "off" then + p.py = startPy + dz * 16 * math.min(frame, AZALEA_STEP_FRAMES) / AZALEA_STEP_FRAMES + p.animClock = (p.animClock or 0) + 1 + if frame >= AZALEA_STEP_FRAMES then + p.cellY = p.cellY + dz + p.px, p.py = p.cellX * 16, p.cellY * 16 + if phase == "off" then return true end + phase, frame = "wait", 0 + end + elseif phase == "wait" then + if frame > 4 then phase, frame = "ride", 0 end + elseif phase == "ride" then + local done = Azalea.step(ride) + azaleaPlace(ctx, { index = ride.spider, x = ride.x, z = ride.z }) + p.px, p.py = ride.x, ride.z + ride.playerZOffset * 16 + if done then + local last = ride.nodes[#ride.nodes] + p.cellX, p.cellY = last.x, last.z + ride.playerZOffset + p.px, p.py = p.cellX * 16, p.cellY * 16 + finish() + phase, frame = "land", 0 + end + elseif phase == "land" then + if frame >= 16 then phase, frame, startPy = "off", 0, p.py end + end + return false + end + ctx.runner:yield() +end +Commands.meta.g4_azalea_gym_spinarak = { blocking = true } + +-- HGSS's battle outcome as the script reads it (constants/battle.h): +-- NONE 0, WIN 1, LOSE 2, DRAW 3, MON_CAUGHT 4, PLAYER_FLED 5 +local HGSS_OUTCOME = { win = 1, lose = 2, draw = 3, caught = 4, run = 5, fled = 5 } +function Commands.g4_static_outcome(ctx, destVar) + setVar(ctx.save, destVar, HGSS_OUTCOME[ctx.lastBattleResult or ""] or 0) +end +-- StaticWildWonOrCaughtCheck: IsBattleResultStaticWildWin, which -- despite +-- the name -- answers TRUE unless the Pokemon was beaten or caught +function Commands.g4_static_not_won(ctx, destVar) + local r = ctx.lastBattleResult + setVar(ctx.save, destVar, (r == "win" or r == "caught") and 0 or 1) +end +-- GetItemQuantity : Bag_GetQuantity +function Commands.g4_item_quantity(ctx, item, destVar) + local id = itemKey(ctx.game and ctx.game.data, valueOf(ctx, item)) + setVar(ctx.save, destVar, tonumber(ctx.save and ctx.save.inventory and ctx.save.inventory[id]) or 0) +end +-- AddMoney : PlayerProfile_AddMoney, capped at 999,999 +function Commands.g4_add_money(ctx, amount) + local save = ctx.save + if not save then return end + save.money = math.min(999999, (tonumber(save.money) or 0) + (tonumber(amount) or 0)) +end +-- ScrCmd_582 : the special spawn warp (where Dig / an Escape +-- Rope out of the Goldenrod Underground lands), facing south +function Commands.g4_special_spawn(ctx, map, x, y) + if not ctx.save then return end + ctx.save.gen4SpecialSpawn = { header = valueOf(ctx, map), x = valueOf(ctx, x), y = valueOf(ctx, y), + facing = "down" } +end + +-- `ScreenShake ampX, ampY, count, period` (HGSS; ov02_02246548 / _0224663C): +-- the camera's look-at target moves by amp * sin(frame * 360 / period) for +-- `period` frames, `count` times, then is put back. The script waits. +function Commands.g4_screen_shake(ctx, ampX, ampY, count, period) + local ow = ctx.overworld + local ax, ay = valueOf(ctx, ampX) or 0, valueOf(ctx, ampY) or 0 + local n, p = math.max(1, valueOf(ctx, count) or 1), math.max(1, valueOf(ctx, period) or 1) + if ax >= 0x8000 then ax = ax - 0x10000 end + if ay >= 0x8000 then ay = ay - 0x10000 end + local runner = ctx.runner + if not (ow and runner) then return end + local frame = 0 + runner.waitingCheck = function() + frame = frame + 1 + if frame >= n * p then + ow.gen4ShakeOffset = nil + return true + end + local s = math.sin(math.rad((frame % p) * 360 / p)) + ow.gen4ShakeOffset = { ax * s, ay * s } + return false + end + runner:yield() +end +Commands.meta.g4_screen_shake = { blocking = true } + +-- `ScrCmd_523 obj, count, step, ampX, ampZ` (HGSS; sub_0205BED8 / _0205BE28): +-- the object's drawn position is offset by (ampX, ampZ) * sin(angle), the +-- angle stepping `step` degrees a frame, `count` turns of 360, then put back. +-- The script waits for it. +function Commands.g4_object_shake(ctx, obj, count, step, ampX, ampZ) + local npc = objectById(ctx, obj) + local runner = ctx.runner + if not (npc and runner) then return end + local n = math.max(1, math.floor(valueOf(ctx, count) or 1)) + local st = math.max(1, math.floor(valueOf(ctx, step) or 90)) + local ax, az = valueOf(ctx, ampX) or 0, valueOf(ctx, ampZ) or 0 + local angle = 0 + local homeX, homeY = npc.cellX * 16, npc.cellY * 16 + runner.waitingCheck = function() + local s = math.sin(math.rad(angle)) + npc.px, npc.py = homeX + ax * s, homeY + az * s + angle = angle + st + if angle >= 360 then + angle = 0 + n = n - 1 + end + if n <= 0 then + npc.px, npc.py = homeX, homeY + return true + end + return false + end + runner:yield() +end +Commands.meta.g4_object_shake = { blocking = true } + +-- LOAN POKEMON (HGSS; npc_trade.c): Cianwood's Shuckie (trade 6) and Kenya +-- the Spearow (trade 7, with the Route 35 man's mail), handed over out of the +-- /a/1/1/2 trade table at a level the script gives -- the same record and +-- the same fields `g4_trade_start` builds a traded Pokemon from. +Gen4Commands.KENYA_TRADE = 7 +function Commands.g4_give_loan_mon(ctx, trade, level, mapno) + local id = math.floor(tonumber(trade) or -1) + local data = ctx.game and ctx.game.data + local list = data and data.constants and data.constants.gen4Trades + local row = type(list) == "table" and list[id + 1] or nil + local party = ctx.save and ctx.save.party + if not (row and row.species and data and party) then + Logger.warn("gen4 loan: no trade record %s to lend from", tostring(trade)) + return + end + local ok, mon = pcall(function() + return require("src.pokemon.Pokemon").new(data, row.species, math.floor(tonumber(level) or 20)) + end) + if not (ok and type(mon) == "table") then return end + mon.nickname, mon.ot, mon.otId, mon.otGender = row.nickname, row.otName, row.otId, row.otGender + mon.traded = true + mon.loanTrade = id + mon.metLocation = tonumber(mapno) + if row.item and row.item ~= 0 then mon.item = row.item end + if id == Gen4Commands.KENYA_TRADE then mon.kenyaMail = true end + if type(row.ivs) == "table" and type(mon.ivs) == "table" then + local Stats = require("src.pokemon.Stats") + for i, key in ipairs(Stats.ORDER_GEN3) do + if row.ivs[i] then mon.ivs[key] = row.ivs[i] end + end + local okStats, stats = pcall(Stats.calc, data.pokemon[row.species], mon.level, mon.ivs, mon.evs, mon.nature) + if okStats and type(stats) == "table" then mon.stats, mon.hp = stats, stats.hp end + end + party[#party + 1] = mon + local save = ctx.save + save.pokedex = save.pokedex or {} + save.pokedex.seen = save.pokedex.seen or {} + save.pokedex.owned = save.pokedex.owned or {} + save.pokedex.seen[mon.species], save.pokedex.owned[mon.species] = true, true +end +-- NPCTrade_CanGiveUpLoanMon: 0 yes; 1 not the loaned one; 3 has a ball +-- capsule (none here); 4 it would leave fewer than two able Pokemon; 2 it +-- holds an item +function Commands.g4_check_return_loan_mon(ctx, trade, slot, destVar) + local party = (ctx.save and ctx.save.party) or {} + local mon = party[(valueOf(ctx, slot) or 0) + 1] + local id = math.floor(tonumber(trade) or -1) + local answer + if not (mon and mon.loanTrade == id) then + answer = 1 + else + local able = 0 + for _, m in ipairs(party) do + if not m.isEgg and (tonumber(m.hp) or 0) > 0 then able = able + 1 end + end + if able < 2 then answer = 4 + elseif mon.item and mon.item ~= 0 then answer = 2 + else answer = 0 end + end + setVar(ctx.save, destVar, answer) +end +function Commands.g4_return_loan_mon(ctx, slot) + local party = ctx.save and ctx.save.party + local i = (valueOf(ctx, slot) or 0) + 1 + if party and party[i] then table.remove(party, i) end +end +-- KenyaCheckPartyOrMailbox: is Kenya's mail still with you (on a party +-- Pokemon -- the mailbox is not built) +function Commands.g4_kenya_mail(ctx, destVar) + local found = false + for _, m in ipairs((ctx.save and ctx.save.party) or {}) do + if m.kenyaMail and m.item and m.item ~= 0 then found = true end + end + setVar(ctx.save, destVar, found and 1 or 0) +end + +-- ScrCmd_775 (HGSS; ov02_022469B4): Lance and his Dragonite +-- fly off the Lake of Rage, and the script hides both straight after. The +-- cartridge's flight path is not transcribed: both rise off the top of the +-- screen, 6 units a frame for 40 frames, and the script waits for it. +function Commands.g4_fly_off_pair(ctx, objA, objB) + local a, b = objectById(ctx, objA), objectById(ctx, objB) + local runner = ctx.runner + if not runner or not (a or b) then return end + local frames = 0 + runner.waitingCheck = function() + frames = frames + 1 + for _, o in ipairs({ a, b }) do if o then o.py = o.py - 6 end end + return frames >= 40 + end + runner:yield() +end +Commands.meta.g4_fly_off_pair = { blocking = true } + +-- LT. SURGE'S GYM (HGSS; gymmick_init.c, field/gymmick/vermilion.c): fifteen +-- cans in a 5 x 3 grid; the first switch is a random can, the second a +-- random neighbour of it. With the Thunder Badge both gates stand open. +-- VermilionGymCanCheck answers 1 / 0 for the first switch, then 2 / 3 for +-- the second once gate 1 is open, and 4 when both are. +Gen4Commands.VERMILION_CAN_NEIGHBOURS = { + [0] = { 1, 5 }, { 0, 2, 6 }, { 1, 3, 7 }, { 2, 4, 8 }, { 3, 9 }, + { 0, 6, 10 }, { 1, 5, 7, 11 }, { 2, 6, 8, 12 }, { 3, 7, 9, 13 }, { 4, 8, 14 }, + { 5, 11 }, { 6, 10, 12 }, { 7, 11, 13 }, { 8, 12, 14 }, { 9, 13 }, +} +local function vermilionState(ctx) + local g = ctx.save and ctx.save.hgssGymmick + if not (g and g.kind == "vermilion") then + g = { kind = "vermilion", switches = { 0, 0 }, gates = { false, false } } + if ctx.save then ctx.save.hgssGymmick = g end + end + return g +end +local function placeVermilionSwitches(g) + g.switches[1] = math.random(0, 14) + local n = Gen4Commands.VERMILION_CAN_NEIGHBOURS[g.switches[1]] + g.switches[2] = n[math.random(1, #n)] +end +function Commands.g4_vermilion_gym_init(ctx) + if not ctx.save then return end + local g = { kind = "vermilion", switches = { 0, 0 }, gates = { false, false } } + ctx.save.hgssGymmick = g + Commands.g4_check_badge(ctx, 10) -- BADGE_THUNDER + if ctx.g4Compare == 1 then + g.gates = { true, true } + else + placeVermilionSwitches(g) + end +end +function Commands.g4_vermilion_resample(ctx) placeVermilionSwitches(vermilionState(ctx)) end +function Commands.g4_vermilion_can_check(ctx, can, destVar) + local g = vermilionState(ctx) + local id = math.floor(tonumber(can) or -1) + local answer + if g.gates[1] and g.gates[2] then answer = 4 + elseif g.gates[1] then answer = (id == g.switches[2]) and 2 or 3 + else answer = (id == g.switches[1]) and 1 or 0 end + setVar(ctx.save, destVar, answer) +end +-- VermilionGymLockAction : the gate's three stops (objects +-- 3, 4, 5 for gate 0; 0, 1, 2 for gate 1) run two tiles apart -- the first +-- two left, the third right -- or back together on a relock +function Commands.g4_vermilion_lock_action(ctx, lock, relock) + local g = vermilionState(ctx) + local n = math.floor(tonumber(lock) or 0) + local back = (tonumber(relock) or 0) ~= 0 + g.gates[n + 1] = not back + local ow = ctx.overworld + if not (ow and ow.scriptMove) then return end + local ids = n == 0 and { 3, 4, 5 } or { 0, 1, 2 } + local moving = 0 + for i, id in ipairs(ids) do + local e = objectById(ctx, id) + if e then + local dir = (i < 3) ~= back and "left" or "right" + moving = moving + 1 + ow:scriptMove(e, dir, 2, function() moving = moving - 1 end, true, 0.5) + end + end + local runner = ctx.runner + if runner and moving > 0 then + runner.waitingCheck = function() return moving <= 0 end + runner:yield() + end +end +Commands.meta.g4_vermilion_lock_action = { blocking = true } +-- FuchsiaGymInit / ViridianGymInit set the gymmick and nothing else: Janine's +-- invisible walls are the map's own collision (the gymmick only flashes one +-- when bumped) and Viridian's spin tiles are forced movement +function Commands.g4_kanto_gym_init(ctx, kind) + if ctx.save then ctx.save.hgssGymmick = { kind = kind } end +end +-- ScrCmd_452 (DrawPokemonPicFromSpecies at tile (10, 5), +-- an 80 x 80 front picture in a framed window) and ScrCmd_453 (the window +-- goes): Latias / Latios on Vermilion's pier. The dex marks it seen. The +-- script runs on under it, so the window is Gen 2's passive pokepic box +-- (src/ui/PicBox) at the DS's place and size; the script's own message and +-- button wait follow it. +function Commands.g4_show_mon_pic(ctx, species, gender) + local id = math.floor(valueOf(ctx, species) or 0) + local save = ctx.save + if not save or id == 0 then return end + save.pokedex = save.pokedex or {} + save.pokedex.seen = save.pokedex.seen or {} + save.pokedex.seen[id] = true + local ow, game = ctx.overworld, ctx.game + if not (ow and game) then return end + local path = require("src.pokemon.Sprites").path(game.data, id, "front", { kind = "pokepic" }) + if not path then return end + if ow.pokepicBox then ow.pokepicBox:remove() end + local box = require("src.ui.PicBox").new(game, { + path = path, trueColor = true, passive = true, overworld = ow, + -- the frame at DS tile (9, 4); PicBox's grid is the GB screen centred + -- in the DS one (6 tiles in, 3 down) + box = { x = 3, y = 1, w = 12, h = 12 }, picTiles = 10, + }) + ow.pokepicBox = box + game.stack:push(box) +end +function Commands.g4_close_mon_pic(ctx) + local ow = ctx.overworld + local box = ow and ow.pokepicBox + if box then box:remove() end +end + +-- CLAIR'S GYM (HGSS; field/gymmick/blackthorn.c): InitBlackthornGym on every +-- entry, then the platforms run from the map def's hooks +-- (src/world/HgssBlackthornGym) +function Commands.g4_blackthorn_gym_init(ctx) + local ow = ctx.overworld + if ow and ctx.save then require("src.world.HgssBlackthornGym").attach(ow, ctx.save) end +end + +-- CHUCK'S GYM (HGSS; field/gymmick/cianwood.c): InitCianwoodGym leaves the +-- winch unturned; CianwoodGymTurnWinch turns it once (answering 1, else 0), +-- saved, while the camera pans 24 frames to (50, 58), the waterfall's props +-- (173..175) play their stopping animation, and it pans 24 back. The prop +-- animations are not played here; the pans and a stand-in for the +-- animation's length are waited out. +function Commands.g4_cianwood_gym_init(ctx) + if ctx.save then ctx.save.hgssGymmick = { kind = "cianwood", winch = false } end +end +function Commands.g4_cianwood_turn_winch(ctx, destVar) + local g = ctx.save and ctx.save.hgssGymmick + local turned = g and g.kind == "cianwood" and not g.winch + if turned then g.winch = true end + setVar(ctx.save, destVar, turned and 1 or 0) + local runner = ctx.runner + if not (turned and runner) then return end + local frames = 0 + runner.waitingCheck = function() frames = frames + 1; return frames >= 24 + 30 + 24 end + runner:yield() +end +Commands.meta.g4_cianwood_turn_winch = { blocking = true } + +-- MORTY'S GYM (HGSS) -- src/world/HgssEcruteakGym: the fog, and a light on +-- each medium that follows her and goes out when she is beaten. +local function ecruteakFollow(ow) + local Game = require("src.core.Game") + local save = Game and Game.save + local g = save and save.hgssGymmick + local def = ow.map and ow.map.def + if not (g and g.kind == "ecruteak" and def) then return end + local ground = ow.map and ow.map.renderer and ow.map.renderer.gen4Ground + local heightAt = ground and ground.groundY and function(x, z) return ground:groundY(x, z) end + local props = require("src.world.HgssEcruteakGym").props(g, ow.npcs, ow.g4CandleDying, heightAt) + local parts = {} + for i, p in ipairs(props) do parts[i] = ("%d,%d,%d,%s"):format(p.x, p.z, p.y, tostring(p.scale)) end + local key = table.concat(parts, ";") + if key ~= ow.g4CandleKey then + ow.g4CandleKey = key + def.gen4RuntimeProps = props + end +end +function Commands.g4_ecruteak_gym_init(ctx) + local E = require("src.world.HgssEcruteakGym") + if ctx.save then ctx.save.hgssGymmick = E.initialState() end + local ow = ctx.overworld + local def = ow and ow.map and ow.map.def + if not def then return end + def.gen4Fog = E.FOG + def.gen4PropFollow = ecruteakFollow + ow.g4CandleKey, ow.g4CandleDying = nil, nil + ecruteakFollow(ow) + Logger.info("gen4 ecruteak gym: fogged, four candles lit") +end +-- ExtinguishEcruteakGymCandle : 1 the trainer the player spoke to, 0 +-- the one who saw them -- both are the script's own object here +function Commands.g4_ecruteak_candle_out(ctx) + local E = require("src.world.HgssEcruteakGym") + local g = ctx.save and ctx.save.hgssGymmick + local ow, npc = ctx.overworld, ctx.npc + if not (g and g.kind == "ecruteak" and ow and npc and npc.def) then + Logger.warn("gen4 ecruteak gym: no candle to put out (gymmick %s, trainer object %s)", + tostring(g and g.kind), tostring(npc and npc.def and npc.def.localId)) + return + end + local i = E.candleOf(npc.def.localId) + if not i or g.candles[i] then return end + local function out() + g.candles[i] = true + ow.g4CandleDying = nil + local data = ctx.game and ctx.game.data + local sprite, name = ow.gen4SpriteFor and ow.gen4SpriteFor(data, E.OUT_SPRITE) + if sprite and npc.refreshSprite then + local copy = {} + for k, v in pairs(npc.def) do copy[k] = v end + copy.sprite, copy.spriteName, copy.graphicsId = sprite, name, E.OUT_SPRITE + npc.def = copy + pcall(npc.refreshSprite, npc, data) + end + ecruteakFollow(ow) + end + local runner = ctx.runner + if not runner then out() return end + local frame, factor = 0, 1 + runner.waitingCheck = function() + frame = frame + 1 + if frame <= 30 then return false end + if factor >= 4 then out() return true end + factor = factor + 1 + ow.g4CandleDying = { index = i, factor = factor } + ecruteakFollow(ow) + return false + end + runner:yield() +end +Commands.meta.g4_ecruteak_candle_out = { blocking = true } + +-- APRICORNS (HGSS): the trees, the box and Kurt -- src/world/HgssApricorns. +local function apricornTreeOf(ctx) + local def = ctx.npc and (ctx.npc.def or ctx.npc) + return def and def.apricornTree +end +-- AnimApricornTree: 1 when the tree had its Apricorn (now shaken off), 0 +-- when it was bare or is not a tree; two 16-frame wiggles either way +function Commands.g4_apricorn_tree_shake(ctx, destVar) + local A = require("src.world.HgssApricorns") + local idx = apricornTreeOf(ctx) + local had = idx ~= nil and ctx.save ~= nil and A.treeHasFruit(ctx.save, idx) + if had then A.pick(ctx.save, idx) end + setVar(ctx.save, destVar, had and 1 or 0) + local runner = ctx.runner + if not (runner and idx) then return end + local frames = 0 + runner.waitingCheck = function() frames = frames + 1; return frames >= 32 end + runner:yield() +end +Commands.meta.g4_apricorn_tree_shake = { blocking = true } +function Commands.g4_apricorn_tree_colour(ctx, destVar) + setVar(ctx.save, destVar, require("src.world.HgssApricorns").treeColour(apricornTreeOf(ctx))) +end +function Commands.g4_apricorn_give(ctx, colour, n, destVar) + local A = require("src.world.HgssApricorns") + local ok = ctx.save and A.give(ctx.save, math.floor(valueOf(ctx, colour) or 0), math.floor(valueOf(ctx, n) or 1)) + setVar(ctx.save, destVar, ok and 1 or 0) +end +function Commands.g4_buffer_apricorn(ctx, slot, colour) + local A = require("src.world.HgssApricorns") + local c = math.floor(valueOf(ctx, colour) or 0) + if c < 0 or c >= 7 then c = 0 end + Commands.g4_buffer(ctx, slot, "bank:" .. A.NAME_BANK, A.NAME_FIRST + c) +end +function Commands.g4_apricorn_total(ctx, destVar) + setVar(ctx.save, destVar, ctx.save and require("src.world.HgssApricorns").total(ctx.save) or 0) +end +function Commands.g4_kurt_quantity(ctx, destVar) + setVar(ctx.save, destVar, ctx.save and require("src.world.HgssApricorns").kurtQuantity(ctx.save) or 0) +end +function Commands.g4_kurt_ball(ctx, destVar) + setVar(ctx.save, destVar, ctx.save and require("src.world.HgssApricorns").kurtBall(ctx.save) or 4) +end +function Commands.g4_kurt_clear(ctx) + if ctx.save then require("src.world.HgssApricorns").setKurt(ctx.save, 0, 0) end +end +-- ScrCmd_739: the Apricorn Box opened to hand Kurt some -- which colour, how +-- many. A cancel leaves Kurt's quantity 0, which is how the script knows. +function Commands.g4_apricorn_box_for_kurt(ctx) + local A = require("src.world.HgssApricorns") + local save, runner = ctx.save, ctx.runner + if not save then return end + local data = ctx.game and ctx.game.data or {} + local Text = require("src.import.Gen4Text") + local rows = {} + local function done() if runner then runner:resume() end end + for c = 0, 6 do + local n = A.count(save, c) + if n > 0 then + local name = data.text and data.text[Text.label(A.NAME_BANK, A.NAME_FIRST + c)] or ("APRICORN " .. c) + rows[#rows + 1] = { label = ("%s x%d"):format(name, n), onSelect = function() + ctx.game.stack:push(require("src.ui.QuantityBox").new(ctx.game, { max = n, onDone = function(q) + if q and q > 0 then A.setKurt(save, c, q) end + done() + end })) + end } + end + end + if #rows == 0 then return end + ctx.game.stack:push(require("src.ui.Menu").new(ctx.game, rows, { cancelable = true, onCancel = done })) + if runner then runner:yield() end +end +Commands.meta.g4_apricorn_box_for_kurt = { foreground = true, blocking = true } + +-- Save_SysInfo_SetMysteryGiftActive(.., 1); read by the title menu +function Commands.g4_unlock_mystery_gift(ctx) + if ctx.save then ctx.save.gen4MysteryGiftUnlocked = true end +end + +function Commands.g4_bank_transaction(ctx, mode, destVar) + Logger.warn("gen4 bank: Mom's amount dial is not built -- the %s answers cancelled", + tonumber(mode) == 0 and "deposit" or "withdrawal") + setVar(ctx.save, destVar, 1) +end + function Commands.g4_starter_species(ctx, destVar) setVar(ctx.save, destVar, playerStarter(ctx.save)) end @@ -2560,6 +3407,171 @@ end Commands.meta = Commands.meta or {} Commands.meta.g4_choose_starter = { foreground = true, blocking = true } +-- HEARTGOLD / SOULSILVER: Elm's turntable (src/ui/Gen4HgssStarterSelect), and +-- the choice goes STRAIGHT INTO THE PARTY, as CreateStarter does -- level 5, +-- no held item, a Poke Ball -- because the lab script reads party slot 0 +-- next and gives nothing itself. The app has no cancel on the cartridge; a +-- cache with no turntable models (imported before the stage) says so and +-- gives nothing. +Gen4Commands.HGSS_STARTERS = { 152, 155, 158 } +function Commands.g4_hgss_choose_starter(ctx) + local runner = ctx.runner + local Screen = require("src.ui.Gen4HgssStarterSelect") + ctx.game.stack:push(Screen.new(ctx.game, { + species = Gen4Commands.HGSS_STARTERS, + onChoose = function(species) + Commands.g4_give_pokemon(ctx, species, 5, 0, nil) + ctx.g4StarterChoice = tonumber(species) + if runner then runner:resume() end + end, + onCancel = function() if runner then runner:resume() end end, + })) + if runner then runner:yield() end +end +Commands.meta.g4_hgss_choose_starter = { foreground = true, blocking = true } + +-- BLACK / WHITE: the gift box (0x153 , src/ui/Gen5StarterSelect). The +-- app only ANSWERS 0..2 -- the bedroom script turns that into a species and +-- gives it with 0x10C itself. +function Commands.g4_bw_choose_starter(ctx, destVar) + local runner = ctx.runner + ctx.game.stack:push(require("src.ui.Gen5StarterSelect").new(ctx.game, { + onChoose = function(index) + setVar(ctx.save, destVar, index) + if runner then runner:resume() end + end, + })) + if runner then runner:yield() end +end +Commands.meta.g4_bw_choose_starter = { foreground = true, blocking = true } + +-- BLACK / WHITE'S SHARED TRAINER SCRIPT (band 3000, member 863): +-- 0x097 1 when that trainer is beaten +-- 0x092 after a win: 1 when the trainer heals you (its +-- record's byte 0x10, trainer-data field 9), 2 when it has a prize +-- item (0x12, field 11), else 0 -- the handler asks the fields in +-- that order +-- 0x066 that object's movement type; 51..54 are the four +-- that make the script turn the trainer before it speaks +function Commands.g4_bw_trainer_beaten(ctx, trainer, destVar) + local id = math.floor(valueOf(ctx, trainer) or 0) + local t = trainerFlags(ctx.save) + setVar(ctx.save, destVar, (t and t[id]) and 1 or 0) +end +function Commands.g4_bw_trainer_after(ctx, trainer, destVar) + local id = math.floor(valueOf(ctx, trainer) or 0) + local data = ctx.game and ctx.game.data + local rec = data and data.trainers and data.trainers[id] + local value = 0 + if rec and rec.healer then value = 1 elseif rec and (tonumber(rec.prizeItem) or 0) > 0 then value = 2 end + setVar(ctx.save, destVar, value) +end +function Commands.g4_bw_object_movement(ctx, destVar, object) + local e = objectById(ctx, object) + local mt = e and e.def and tonumber(e.def.movementType) or 0 + setVar(ctx.save, destVar, mt) +end + +-- BLACK / WHITE 0x025 : the flag's state into a var -- the flag +-- a var-or-literal (82 sites: "has this already happened" checks that a +-- script then branches on with the direct compare) +function Commands.g4_bw_flag_to_var(ctx, flag, destVar) + local key = ('FLAG_G4_%04X'):format(math.floor(valueOf(ctx, flag) or 0)) + setVar(ctx.save, destVar, ((ctx.save and ctx.save.flags) or {})[key] and 1 or 0) +end + +-- BLACK / WHITE 0x0E0 : the game's version id, a constant in the +-- handler (`movs r1, #0x15`: Black is 21, White 20 -- Gen 5's ids). Scripts +-- pick the version's branch with `if (var == 21)`. +function Commands.g4_bw_version(ctx, destVar) + local white = require("src.core.GameVersion").get() == "white" + setVar(ctx.save, destVar, white and 20 or 21) +end + +-- BLACK / WHITE 0x0D0 : today's date, from the clock. +function Commands.g4_bw_date(ctx, monthVar, dayVar) + setVar(ctx.save, monthVar, tonumber(os.date("%m")) or 1) + setVar(ctx.save, dayVar, tonumber(os.date("%d")) or 1) +end +-- 0x0D4 : the birthday in the DS's own user profile, which +-- every nurse compares with today ("Hey! Is today your birthday?"). A PC has +-- no such profile; save.player.birthday = { month, day } supplies one, and +-- without it both read 0 -- no day of the year, so the greeting never fires. +function Commands.g4_bw_birthday(ctx, monthVar, dayVar) + local b = ctx.save and ctx.save.player and ctx.save.player.birthday + setVar(ctx.save, monthVar, type(b) == "table" and tonumber(b.month or b[1]) or 0) + setVar(ctx.save, dayVar, type(b) == "table" and tonumber(b.day or b[2]) or 0) +end + +-- BLACK / WHITE 0x110 : 1 when that party Pokemon carries a +-- nickname (Juniper's "Are you OK with the nickname {VAR1}?" runs on 1, and +-- the loop goes round again after a NO) +function Commands.g4_mon_has_nickname(ctx, destVar, slotVar) + local slot = math.floor(valueOf(ctx, slotVar) or 0) + local mon = ctx.save and ctx.save.party and ctx.save.party[slot + 1] + local data = ctx.game and ctx.game.data + local def = mon and data and data.pokemon and data.pokemon[mon.species] + local nick = mon and mon.nickname + local has = type(nick) == "string" and nick ~= "" and nick ~= (def and def.name) + setVar(ctx.save, destVar, has and 1 or 0) +end + +-- BLACK / WHITE 0x101 : 1 when that party Pokemon's HP is full +-- (MON_DATA 0xA0 == 0xA1) or it is an Egg (0x4C), else 0 -- Cheren's "you +-- won without your Pokemon's losing any energy" asks it after Bianca's battle +function Commands.g4_mon_hp_full(ctx, destVar, slotVar) + local slot = math.floor(valueOf(ctx, slotVar) or 0) + local mon = ctx.save and ctx.save.party and ctx.save.party[slot + 1] + local full = 0 + if mon then + local max = mon.stats and tonumber(mon.stats.hp) + if mon.isEgg or (max and (tonumber(mon.hp) or 0) >= max) then full = 1 end + end + setVar(ctx.save, destVar, full) +end + +-- `PlaceStarterBallsInElmsLab` (scrcmd_c.c): building model 0x8D, `labo_mb`, +-- at (131, 0, 65), (141, 0, 65) and (136, 0, 72) -- on the machine -- as many +-- as are still there: none after Falkner's TM51, one after meeting the +-- passer-by boy, two once you have a Pokemon, otherwise three. +-- ScrCmd_GetFriendSprite: SPRITE_HERO (0) unless the player is the boy, then +-- SPRITE_HEROINE (97) -- pokeheartgold's constants/sprites.h +function Commands.g4_friend_sprite(ctx, var) + local girl = ctx.save and ctx.save.player and ctx.save.player.gender == "girl" + setVar(ctx.save, var, girl and 0 or 97) +end + +Gen4Commands.ELMS_LAB_BALLS = { { 131, 0, 65 }, { 141, 0, 65 }, { 136, 0, 72 } } +function Commands.g4_place_starter_balls(ctx) + local ow = ctx.overworld + local def = ow and ow.map and ow.map.def + if not def then return end + local function flag(id) + Commands.check_flag(ctx, ("FLAG_G4_%04X"):format(id)) + return ctx.lastCheck and true or false + end + local party = ctx.save and ctx.save.party or {} + local n = flag(0x73) and 0 or flag(0x99) and 1 or (#party > 0 and 2) or 3 + local props = {} + for i = 1, n do + local c = Gen4Commands.ELMS_LAB_BALLS[i] + props[i] = { model = 0x8D, x = c[1], y = c[2], z = c[3] } + end + def.gen4RuntimeProps = props +end + +-- `SetStarterChoice `: Save_VarsFlags_SetStarter, the same field +-- `savechosenstarter` writes on Platinum. +function Commands.g4_set_starter_choice(ctx, var) + local species = math.floor(valueOf(ctx, var) or 0) + if species == 0 then return end + setVar(ctx.save, VAR_PLAYER_STARTER, species) + if ctx.save then + ctx.save.gen4Starter = species + if ctx.save.player then ctx.save.player.starter = species end + end +end + -- `givepokemon ` -- every one of the -- first three is a VAR-or-literal (`ScriptContext_GetVar`), which matters here -- because the starter is handed over as `givepokemon 0x8000, 5, 0, 0x800C` @@ -2894,15 +3906,30 @@ end -- battle the script did not start. function Commands.g4_check_won_battle(ctx, destVar) local won = (ctx.lastBattleResult == "win") + Logger.debug("gen4 script: the last battle ended %s", tostring(ctx.lastBattleResult)) -- ...and mark the trainer beaten, once, on the way past. The cartridge does -- this in the battle teardown rather than the script -- no Sinnoh script -- runs `settrainerflag` after a fight -- so a port that waited for one would -- have every trainer in the region challenge you again for ever. Same shape -- as the Gen 3 fix in `start_battle`. - if won and ctx.g4Trainer then + -- ...EXCEPT ON HEARTGOLD / SOULSILVER, whose shared trainer script + -- (scr_seq_0953) asks GoToIfDefeated right after CheckBattleWon and only + -- then runs SetTrainerFlag itself: marked here, every first win took the + -- rematch branch (and its phone-gift rows). + local hgss = require("src.import.Gen4ScriptBands").family == "hgss" + -- HGSS's CheckBattleWon is IsBattleResultWin (battle_setup.c): anything but + -- LOSE or DRAW, so catching or fleeing from a static Pokemon counts -- + -- Route 36's Sudowoodo otherwise white-outs on a catch + if hgss then + local r = ctx.lastBattleResult + won = r ~= "lose" and r ~= "draw" + end + if won and ctx.g4Trainer and not hgss then local t = trainerFlags(ctx.save) if t then t[ctx.g4Trainer] = true end ctx.g4Trainer = nil + elseif hgss then + ctx.g4Trainer = nil end local value = won and 1 or 0 if destVar then setVar(ctx.save, destVar, value) end @@ -3141,8 +4168,16 @@ function Commands.g4_print_trainer_dialogue(ctx, trainer, messageType) id, kind) return end - return Commands.show_text(ctx, - Gen4Text.label(Gen4TrainerMessages.BANK, entry)) + -- THE BANK IS THE CARTRIDGE'S: 617 on Platinum, 728 on HeartGold / + -- SoulSilver (trainer_data.c, msg_0728). HGSS's gym trainers printed their + -- own label, TEXT_B0617_00134, until this asked. + local bank = (require("src.import.Gen4ScriptBands").family == "hgss") and 728 + or Gen4TrainerMessages.BANK + -- Black / White: system bank 189 (RomExtractorGen5's index) + if require("src.import.Gen4ScriptBands").family == "bw" then + return Commands.show_text(ctx, ("TEXT_G5S_0189_%05d"):format(entry)) + end + return Commands.show_text(ctx, Gen4Text.label(bank, entry)) end -- `checkhastwoalivemons ` -- `Party_HasTwoAliveMons`, which is what @@ -3393,8 +4428,24 @@ function Commands.g4_name_pokemon(ctx, slotArg, destVar) local runner = ctx.runner local Screens = require("src.ui.Screens") + -- THE TITLE IS THE NAMING BANK'S OWN, entry 1 "{STRVAR_1}'s nickname?" -- + -- bank 422 on Platinum, 249 on HeartGold / SoulSilver -- with the species + -- in the slot. Without it the screen asked "YOUR NAME?". + local T = require("src.import.Gen4Text") + local bank = (require("src.import.Gen4ScriptBands").family == "hgss") and 249 or 422 + local template = ctx.game.data and ctx.game.data.text and ctx.game.data.text[T.label(bank, 1)] + -- Black / White: system bank 81, "{VAR1}'s nickname?" (the story bank of + -- the same number is some town's lines) + if require("src.import.Gen4ScriptBands").family == "bw" then + template = ctx.game.data.text and ctx.game.data.text["TEXT_G5S_0081_00001"] + end + local title = type(template) == "string" + and template:gsub("%s*{YESNO %d+}", ""):gsub("{STRVAR_1 [^}]*}", function() return speciesName end) + :gsub("{VAR1}", function() return speciesName end) + or nil Screens.push(ctx.game, "NamingScreen", { kind = "pokemon", + title = title, species = mon.species, mon = mon, default = before, @@ -3622,7 +4673,7 @@ function Commands.g4_item_pocket(ctx, item, destVar) local data = ctx.game and ctx.game.data local id = itemKey(data, math.floor(valueOf(ctx, item) or 0)) local def = data and data.items and data.items[id] - local pocket = tonumber(def and def.fieldPocket) or 0 + local pocket = tonumber(def and (def.bwPocket or def.fieldPocket)) or 0 if destVar then setVar(ctx.save, destVar, pocket) end setResult(ctx, pocket) end @@ -3907,6 +4958,27 @@ function Commands.g4_player_state(ctx, state) elseif math.floor(bits / 2) % 2 == 1 then -- CYCLING save.onBike = true end + -- HGSS: PLAYER_TRANSITION_ROCKET (1 << 10) puts on the Rocket disguise + -- (the Radio Tower), WALKING takes it off; the sheet follows + -- (Player:refreshForm) + if require("src.import.Gen4ScriptBands").family == "hgss" then + local was = save.hgssRocketCostume + if math.floor(bits / 1024) % 2 == 1 then save.hgssRocketCostume = true + elseif bits % 2 == 1 then save.hgssRocketCostume = nil end + local p = ctx.overworld and ctx.overworld.player + if was ~= save.hgssRocketCostume and p and p.refreshForm and ctx.game then + p.walkId = nil + pcall(p.refreshForm, p, ctx.game.data) + end + end +end +-- RocketCostumeFlagAction / RocketCostumeFlagCheck : the save's +-- own costume flag, which the Radio Tower's guards read +function Commands.g4_rocket_costume_flag(ctx, on) + if ctx.save then ctx.save.hgssRocketCostumeFlag = (tonumber(on) or 0) ~= 0 or nil end +end +function Commands.g4_rocket_costume_check(ctx, destVar) + setVar(ctx.save, destVar, (ctx.save and ctx.save.hgssRocketCostumeFlag) and 1 or 0) end -- `changeplayerstate` is `PlayerAvatar_RequestChangeState` -- the LATCH that @@ -4038,6 +5110,22 @@ function Commands.g4_give_egg(ctx, species, giver) Commands.give_pokemon(ctx, id, 1, true, { egg = true }) end +-- ScrCmd_GiveTogepiEgg (field/scrcmd_pokemon_misc.c): an Egg of Togepi, +-- level 1, with MOVE_EXTRASENSORY (326) in the first empty move slot -- the +-- last one if all four are taken -- and nothing at all with a full party. +function Commands.g4_give_togepi_egg(ctx) + local party = (ctx.save and ctx.save.party) or {} + if #party >= 6 then return end + local before = #party + Commands.g4_give_egg(ctx, 175, 0) + local mon = ctx.save and ctx.save.party and ctx.save.party[before + 1] + if not mon then return end + mon.moves = mon.moves or {} + local mdef = ctx.game and ctx.game.data and ctx.game.data.moves and ctx.game.data.moves[326] + local slot = { id = 326, pp = mdef and mdef.pp or 0 } + if #mon.moves < 4 then mon.moves[#mon.moves + 1] = slot else mon.moves[4] = slot end +end + -- `startlegendarybattle ` -- `Encounter_NewVsSpeciesAtLevel` -- with the legendary flag on. NOT only legendaries: the one site on the -- critical path is the DRIFLOON that appears at the Valley Windworks on @@ -5101,7 +6189,8 @@ function Commands.g4_pc_free_slots(ctx, destVar) end function Commands.g4_storage(ctx, mode) - local modes = { [0] = 'deposit', [1] = 'withdraw', [2] = 'move', [3] = 'items' } + -- box_settings.h: PC_MODE_DEPOSIT, _WITHDRAW, _MOVE, _MOVE_ITEMS, _COMPARE + local modes = { [0] = 'deposit', [1] = 'withdraw', [2] = 'move', [3] = 'items', [4] = 'compare' } local done = false require('src.ui.Screens').push(ctx.game, 'BoxMenu', { mode = modes[valueOf(ctx, mode)] or 'move', onCancel = function() done = true end, @@ -5480,6 +6569,7 @@ end -- a var id. function Commands.g4_release_roamer(ctx, slot) local Roamers = require("src.world.Gen4Roamers") + Roamers.useFamily() -- HGSS's four slots on a HeartGold / SoulSilver cache local data = ctx.game and ctx.game.data local id = math.floor(tonumber(slot) or -1) local def = Roamers.SLOTS[id] @@ -6274,6 +7364,11 @@ function Commands.g4_buffer_type_name(ctx, slot, typeVar) local Gen4Text = require("src.import.Gen4Text") local data = ctx.game and ctx.game.data local text = data and data.text and data.text[Gen4Text.label(624, t)] + -- Black / White: the constants stage's own type names (Gen 5's order) + if require("src.import.Gen4ScriptBands").family == "bw" then + local types = data and data.constants and data.constants.types + text = types and types[t] + end if not ctx.game then return end ctx.game.stringBuffers = ctx.game.stringBuffers or {} ctx.game.stringBuffers[(tonumber(slot) or 0) + 1] = text or "" @@ -7024,10 +8119,17 @@ Commands.meta.g4_mystery_gift_offer = { foreground = true, blocking = true } -- the game had been cleared before this time -- 5. OS_ResetSystem: the cartridge reboots, so this returns to the title -- and the script never resumes -function Commands.g4_clear_game(ctx) +-- HEARTGOLD / SOULSILVER reach it as `HOFCredits ` +-- (game_clear.c, CallTask_GameClear): the same flag (0x964) and order, the +-- saving lines out of msg_0040 (15, 16) rather than 213, and after Red on +-- Mt. Silver (vsTrainerRed 1) no Hall of Fame screen and no entry -- the +-- save and the credits only. +function Commands.g4_clear_game(ctx, vsTrainerRed) local game, save, runner = ctx.game, ctx.save, ctx.runner + local hgss = require("src.import.Gen4ScriptBands").family == "hgss" + local vsRed = hgss and (tonumber(vsTrainerRed) or 0) ~= 0 -- clear_game.c: RECORD_TIMES_ENTERED_HALL_OF_FAME - require("src.pokemon.Gen4GameRecords").increment(save, 73) + if not vsRed then require("src.pokemon.Gen4GameRecords").increment(save, 73) end local flag = (save.flags or {})[Gen4Commands.GAME_COMPLETED_FLAG] local clearedBefore = flag == true or #((save and save.hallOfFame) or {}) > 0 Commands.g4_prepare_hall_of_fame(ctx) @@ -7035,7 +8137,7 @@ function Commands.g4_clear_game(ctx) local TextBox = require("src.render.TextBox") local function line(n) T.buffer(game, (save.player and save.player.name) or "") - return T.resolve(game.data, 213, n, game) or "" + return T.resolve(game.data, hgss and 40 or 213, n, game) or "" end local function credits() game.stack:push(require("src.ui.Gen4Credits").new(game, { @@ -7059,6 +8161,15 @@ function Commands.g4_clear_game(ctx) end local party = {} for _, mon in ipairs(save.party or {}) do party[#party + 1] = mon end + if vsRed then + if save.flags then save.flags[Gen4Commands.GAME_COMPLETED_FLAG] = true end + game.stack:push(TextBox.new(game, line(15), function() + saveGame() + game.stack:push(TextBox.new(game, line(16), credits)) + end)) + if runner then runner:yield() end + return + end game.stack:push(require("src.ui.Gen4HallOfFame").new(game, { party = party, onDone = function() @@ -7069,12 +8180,15 @@ function Commands.g4_clear_game(ctx) entry[#entry + 1] = { species = mon.species, level = mon.level, nickname = mon.nickname } end table.insert(save.hallOfFame, entry) - require("src.pokemon.Gen4MysteryGift").offer(game, function() + local function afterGift() game.stack:push(TextBox.new(game, line(15), function() saveGame() game.stack:push(TextBox.new(game, line(16), credits)) end)) - end) + end + -- the deliveryman is Platinum's (and this port's addition); HGSS goes + -- straight to the save + if hgss then afterGift() else require("src.pokemon.Gen4MysteryGift").offer(game, afterGift) end end, })) if runner then runner:yield() end diff --git a/src/script/Gen4ScriptVM.lua b/src/script/Gen4ScriptVM.lua index a073e1047..d8872969f 100644 --- a/src/script/Gen4ScriptVM.lua +++ b/src/script/Gen4ScriptVM.lua @@ -269,6 +269,19 @@ L.message = function(ins, s) message(ins.args[1], s) end L.messageinstant = L.message L.messagenoskip = L.message L.messagesynchronized = L.message +-- HEARTGOLD / SOULSILVER's `GenderMsgBox `: one of two entries +-- of the script's own bank by the player's gender (ScrCmd_GenderMsgBox reads +-- PlayerProfile_GetTrainerGender; anything not male takes the second). +L.gendermsgbox = function(ins, s) + emit(s, { "g4_gender_message", ins.args[1], ins.args[2], s.bankFor and s.bankFor(s.member) }) +end +-- ...and `GetStdMsgNaix `, which hands a script one of four +-- shared banks to print from with MsgBoxExtern (scrcmd_message.c, +-- ov01_022067C8): the day-of-week siblings 752, field moves 211, Cameron the +-- photographer 30, the shops 435; anything past the table is 0. +L.getstdmsgnaix = function(ins, s) + emit(s, { "g4_std_msg_bank", ins.args[1], ins.args[2] }) +end L.messagevar = function(ins, s) emit(s, { "g4_message_var", ins.args[1], s.bankFor and s.bankFor(s.member) }) end @@ -337,8 +350,10 @@ L.bufferplayername = function(ins, s) emit(s, { "g4_buffer", ins.args[1], "playe -- `bufferpokemonname` is not an opcode either; the cartridge has one buffer -- for a party member's SPECIES and another for its NICKNAME, plus move, -- number and the counterpart's name. All six are byte-slot-then-word. +-- ...and bufferpartymonspecies's second operand is a PARTY SLOT +-- (ScriptContext_GetVar -> Party_GetPokemonBySlotIndex), not a species. L.bufferpartymonspecies = function(ins, s) - emit(s, { "g4_buffer", ins.args[1], "species", ins.args[2] }) + emit(s, { "g4_buffer", ins.args[1], "partyspecies", ins.args[2] }) end L.bufferpartymonnickname = function(ins, s) emit(s, { "g4_buffer", ins.args[1], "nickname", ins.args[2] }) @@ -1296,6 +1311,182 @@ end L.getplayerstarterspecies = function(ins, s) emit(s, { "g4_starter_species", ins.args[1] }) end +-- HEARTGOLD / SOULSILVER'S STARTER. `ChooseStarter` (ScrCmd_ChooseStarter -> +-- LaunchStarterChoiceScene, choose_starter.c) runs Elm's turntable and then +-- ADDS THE CHOICE TO THE PARTY ITSELF -- Chikorita, Cyndaquil or Totodile at +-- level 5 -- so the lab script follows it with `GetPartyMonSpecies 0` rather +-- than a give. `SetStarterChoice ` stores the species in the save's +-- starter field and `GetStarterChoice ` reads it back. +L.choosestarter = function(_, s) emit(s, { "g4_hgss_choose_starter" }) end +L.setstarterchoice = function(ins, s) emit(s, { "g4_set_starter_choice", ins.args[1] }) end +L.getstarterchoice = function(ins, s) emit(s, { "g4_starter_species", ins.args[1] }) end +L.placestarterballsinelmslab = function(_, s) emit(s, { "g4_place_starter_balls" }) end +-- `GetFriendSprite `: the other hero's overworld sprite -- Ethan's for +-- a girl, Lyra's for a boy -- into the object-graphics var the friend wears. +L.getfriendsprite = function(ins, s) emit(s, { "g4_friend_sprite", ins.args[1] }) end +-- `CatchingTutorial`: the friend catches a Rattata on Route 29 +L.catchingtutorial = function(_, s) emit(s, { "g4_hgss_catching_tutorial" }) end +-- `NameRival `: the naming screen for the rival, var = 1 when nothing +-- was entered (the lab script asks again until something is) +L.namerival = function(ins, s) emit(s, { "g4_name_rival", ins.args[1] }) end +-- THE POKEGEAR AND MOM'S SAVINGS (HGSS), save state only: which cards and +-- phone numbers are registered, how far the map is unlocked, and the balance. +L.registerpokegearcard = function(ins, s) emit(s, { "g4_pokegear_card", ins.args[1] }) end +L.registergearnumber = function(ins, s) emit(s, { "g4_pokegear_number", ins.args[1] }) end +L.checkregisteredphonenumber = function(ins, s) emit(s, { "g4_pokegear_has_number", ins.args[1], ins.args[2] }) end +L.scrcmd_804 = function(ins, s) emit(s, { "g4_pokegear_map_level", ins.args[1] }) end +L.checkbankbalance = function(ins, s) emit(s, { "g4_bank_balance_at_least", ins.args[1], ins.args[2] }) end +L.bankorwalletisfull = function(ins, s) emit(s, { "g4_bank_or_wallet_full", ins.args[1], ins.args[2] }) end +L.banktransaction = function(ins, s) emit(s, { "g4_bank_transaction", ins.args[1], ins.args[2] }) end +-- `GetPersonCoords ` (HGSS): where an object stands +L.getpersoncoords = function(ins, s) emit(s, { "g4_object_coords", ins.args[1], ins.args[2], ins.args[3] }) end +-- `UnlockMysteryGift` (Platinum) / ScrCmd_573 (HGSS, after Oak's Pokedex): +-- the title menu's MYSTERY GIFT entry turns on -- a save flag here +L.unlockmysterygift = function(_, s) emit(s, { "g4_unlock_mystery_gift" }) end +-- CAMERON THE PHOTOGRAPHER (HGSS): CameronPhoto takes photo n into the +-- album (the script fades around it), CountSavedPhotos / PhotoAlbumIsFull +-- read it back; 36 photos (photo_album.h PHOTO_ALBUM_MAX) +L.cameronphoto = function(ins, s) emit(s, { "g4_take_photo", ins.args[1] }) end +L.countsavedphotos = function(ins, s) emit(s, { "g4_count_photos", ins.args[1] }) end +L.photoalbumisfull = function(ins, s) emit(s, { "g4_photo_album_full", ins.args[1] }) end +-- `GiveTogepiEgg` (HGSS): the Mystery Egg's Togepi, as an Egg that knows +-- Extrasensory +L.givetogepiegg = function(_, s) emit(s, { "g4_give_togepi_egg" }) end +-- FALKNER'S GYM (HGSS): the lift. VioletGymInit runs from the gym's entry +-- script; VioletGymElevator carries the player up or down +L.violetgyminit = function(_, s) emit(s, { "g4_violet_gym_init" }) end +L.violetgymelevator = function(_, s) emit(s, { "g4_violet_gym_elevator" }) end +-- BUGSY'S GYM (HGSS): the Spinarak carts and their two switches +L.azaleagyminit = function(_, s) emit(s, { "g4_azalea_gym_init" }) end +L.azaleagymspinarak = function(ins, s) emit(s, { "g4_azalea_gym_spinarak", ins.args[1] }) end +L.azaleagymswitch = function(ins, s) emit(s, { "g4_azalea_gym_switch", ins.args[1] }) end +-- THE HEADBUTT TUTOR in Ilex Forest (HGSS), on Platinum's tutor commands: +-- `PartySelectUI` is the plain party pick GetPartySelection reads; +-- `ScrCmd_656 slot, dest` is GetLearnableTutorMoves(mon, NPC 3) > 0; +-- `PokemonSummaryScreen 1, slot, move` / `GetMoveSelection 1, dest` the +-- forget-a-move pick (4 keeps them); `SetMonMove slot, moveSlot, move` +-- PartyMonSetMoveInSlot. `MonHasMove` and `CountMonMoves` are Platinum's +-- checkpartymonhasmove / getpartymonmovecount, operands in the same order. +-- APRICORNS (HGSS): the trees' shared script (scr_seq_0150) and Kurt's +-- (T23R0501) -- scrcmd_apricorn.c and scrcmd_pokemon_misc.c +L.animapricorntree = function(ins, s) emit(s, { "g4_apricorn_tree_shake", ins.args[1] }) end +L.apricorntreegetapricorn = function(ins, s) emit(s, { "g4_apricorn_tree_colour", ins.args[1] }) end +L.giveapricornfromtree = function(ins, s) + emit(s, { "g4_apricorn_give", ins.args[1], ins.args[2], ins.args[3] }) +end +L.bufferapricornname = function(ins, s) emit(s, { "g4_buffer_apricorn", ins.args[1], ins.args[2] }) end +L.scrcmd_735 = function(ins, s) emit(s, { "g4_kurt_quantity", ins.args[1] }) end +L.clearkurtapricorn = function(_, s) emit(s, { "g4_kurt_clear" }) end +L.scrcmd_737 = function(ins, s) emit(s, { "g4_kurt_ball", ins.args[1] }) end +L.gettotalapricorncount = function(ins, s) emit(s, { "g4_apricorn_total", ins.args[1] }) end +L.scrcmd_739 = function(_, s) emit(s, { "g4_apricorn_box_for_kurt" }) end +-- GOLDENROD AND ROUTES 34-36 (HGSS): small ones, each a Platinum command +-- or a few lines (scrcmd_c.c, scrcmd_party.c, scrcmd_battle.c, scrcmd_8.c) +L.scrcmd_522 = function(ins, s) emit(s, { "g4_get_hour", ins.args[1] }) end +L.getstaticencounteroutcome = function(ins, s) emit(s, { "g4_static_outcome", ins.args[1] }) end +L.staticwildwonorcaughtcheck = function(ins, s) emit(s, { "g4_static_not_won", ins.args[1] }) end +L.countalivemons = function(ins, s) emit(s, { "g4_party_alive_except", ins.args[1], ins.args[2] }) end +L.partycountnotegg = function(ins, s) emit(s, { "g4_party_non_eggs", ins.args[1] }) end +L.checkdaycareegg = function(ins, s) emit(s, { "g4_daycare_has_egg", ins.args[1] }) end +L.getitemquantity = function(ins, s) emit(s, { "g4_item_quantity", ins.args[1], ins.args[2] }) end +L.addmoney = function(ins, s) emit(s, { "g4_add_money", ins.args[1] }) end +L.setobjectmovementtype = function(ins, s) emit(s, { "g4_set_object_movement", ins.args[1], ins.args[2] }) end +L.scrcmd_582 = function(ins, s) emit(s, { "g4_special_spawn", ins.args[1], ins.args[2], ins.args[3] }) end +-- GOLDENROD DEPT. STORE'S LIFT: the floor name is msg_0191 (BufferDeptStore- +-- FloorNo: 0 is B1F, entry 122; n is entry 116 + n - 1); the floor window and +-- the car's rocking are drawn by Platinum's lift as nothing, here likewise +L.bufferdeptstorefloorno = function(ins, s) + local floor = tonumber(ins.args[2]) or 1 + emit(s, { "g4_buffer", ins.args[1], "bank:191", floor == 0 and 122 or (115 + floor) }) +end +L.elevatorcurfloorbox = function(_, s) emit(s, { "g4_noop", "the lift's current-floor window" }) end +L.elevatoranim = function(_, s) emit(s, { "g4_noop", "the elevator animation" }) end +-- THE BURNED TOWER (HGSS): `CreateRoamer n` is Platinum's release on HGSS's +-- four slots (Gen4Roamers / HgssRoamers); `ScrCmd_523 obj, count, step, +-- ampX, ampZ` rattles an object in place (the beasts rearing) +L.createroamer = function(ins, s) emit(s, { "g4_release_roamer", ins.args[1] }) end +L.scrcmd_523 = function(ins, s) + emit(s, { "g4_object_shake", ins.args[1], ins.args[2], ins.args[3], ins.args[4], ins.args[5] }) +end +-- THE ROCKET DISGUISE (HGSS, Radio Tower): SetAvatarBits is Platinum's +-- setplayerstate (the bits), UpdateAvatarState its latch; +-- RocketCostumeFlagAction / Check the save's costume flag +L.setavatarbits = function(ins, s) emit(s, { "g4_player_state", ins.args[1] }) end +L.updateavatarstate = function(_, s) emit(s, { "g4_noop", "the player-state latch (applied when set)" }) end +L.rocketcostumeflagaction = function(ins, s) emit(s, { "g4_rocket_costume_flag", ins.args[1] }) end +L.rocketcostumeflagcheck = function(ins, s) emit(s, { "g4_rocket_costume_check", ins.args[1] }) end +L.scrcmd_436 = function(_, s) emit(s, { "g4_noop", "leaving the overworld for an app" }) end +-- THE ROCKET HIDEOUT (HGSS, Mahogany): the floor traps are wild battles +-- (ScrCmd_RocketTrapBattle: SetupAndStartWildBattle with the species and +-- level); MovePerson is Platinum's setobjecteventpos (the template's x, y); +-- MultiBattle is the tag battle beside Lance +-- in Platinum's operand order; ScrCmd_530 writes sys_vars.c's +-- _020FE4A8[n] (or 0) into var 0x4036 + n. ScrCmd_708 (the trap's flash), +-- 709 (the Persian statues' alarm) and 710 (the B2F doors' prop animations) +-- are pictures only. +L.rockettrapbattle = function(ins, s) emit(s, { "g4_start_wild_battle", ins.args[1], ins.args[2] }) end +L.moveperson = function(ins, s) emit(s, { "g4_set_object_pos", ins.args[1], ins.args[2], ins.args[3] }) end +L.multibattle = function(ins, s) + emit(s, { "g4_start_tag_battle", ins.args[1], ins.args[2], ins.args[3] }) +end +L.scrcmd_530 = function(ins, s) + local magic = { [0] = 0x6208, 0xF229, 0x0382, 0x1228 } + local n = tonumber(ins.args[1]) or 0 + emit(s, { "g4_set_var", 0x4036 + n, (tonumber(ins.args[2]) or 0) ~= 0 and (magic[n] or 0) or 0 }) +end +L.scrcmd_708 = function(_, s) emit(s, { "g4_noop", "the rocket trap's flash" }) end +L.scrcmd_709 = function(_, s) emit(s, { "g4_noop", "the Persian statues' alarm" }) end +L.scrcmd_710 = function(_, s) emit(s, { "g4_noop", "the hideout doors' prop animations" }) end +-- LOAN POKEMON (HGSS): Shuckie in Cianwood, Kenya on Route 35 +L.giveloanmon = function(ins, s) emit(s, { "g4_give_loan_mon", ins.args[1], ins.args[2], ins.args[3] }) end +L.checkreturnloanmon = function(ins, s) + emit(s, { "g4_check_return_loan_mon", ins.args[1], ins.args[2], ins.args[3] }) +end +L.returnloanmon = function(ins, s) emit(s, { "g4_return_loan_mon", ins.args[1] }) end +L.kenyacheckpartyormailbox = function(ins, s) emit(s, { "g4_kenya_mail", ins.args[1] }) end +-- LAKE OF RAGE (HGSS): Lance and Dragonite fly off +L.scrcmd_775 = function(ins, s) emit(s, { "g4_fly_off_pair", ins.args[1], ins.args[2] }) end +-- CHUCK'S GYM (HGSS): the winch that stops the waterfall +L.cianwoodgyminit = function(_, s) emit(s, { "g4_cianwood_gym_init" }) end +L.cianwoodgymturnwinch = function(ins, s) emit(s, { "g4_cianwood_turn_winch", ins.args[1] }) end +-- BLACKTHORN'S MOVE RELEARNER AND TUTORS (HGSS; scrcmd_move_relearner.c): +-- MoveRelearnerInit is Platinum's move reminder, MoveTutorInit +-- its one-move tutor (Draco Meteor and friends), and +-- MoveRelearnerGetResult answers 0 learned / 255 not, as checklearned* does +L.moverelearnerinit = function(ins, s) emit(s, { "g4_open_move_reminder_menu", ins.args[1] }) end +L.movetutorinit = function(ins, s) emit(s, { "g4_open_move_tutor_menu", ins.args[1], ins.args[2] }) end +L.moverelearnergetresult = function(ins, s) emit(s, { "g4_learned_move", ins.args[1] }) end +-- KANTO'S GYMS (HGSS): Lt. Surge's cans, Janine's and Blue's gymmicks +L.vermiliongyminit = function(_, s) emit(s, { "g4_vermilion_gym_init" }) end +L.vermiliongymcancheck = function(ins, s) emit(s, { "g4_vermilion_can_check", ins.args[1], ins.args[2] }) end +L.vermiliongymlockaction = function(ins, s) emit(s, { "g4_vermilion_lock_action", ins.args[1], ins.args[2] }) end +L.resamplevermiliongymcans = function(_, s) emit(s, { "g4_vermilion_resample" }) end +L.fuchsiagyminit = function(_, s) emit(s, { "g4_kanto_gym_init", "fuchsia" }) end +L.viridiangyminit = function(_, s) emit(s, { "g4_kanto_gym_init", "viridian" }) end +-- SetBikeStateLock (Cycling Road): Platinum's forcebicycling +L.setbikestatelock = function(ins, s) emit(s, { "g4_force_bike", ins.args[1] }) end +L.scrcmd_452 = function(ins, s) emit(s, { "g4_show_mon_pic", ins.args[1], ins.args[2] }) end +L.scrcmd_453 = function(_, s) emit(s, { "g4_close_mon_pic" }) end +-- CLAIR'S GYM (HGSS): the platforms over the magma (HgssBlackthornGym) +L.blackthorngyminit = function(_, s) emit(s, { "g4_blackthorn_gym_init" }) end +-- MORTY'S GYM (HGSS): the fog and the mediums' candles +L.ecruteakgyminit = function(_, s) emit(s, { "g4_ecruteak_gym_init" }) end +L.extinguishecruteakgymcandle = function(_, s) emit(s, { "g4_ecruteak_candle_out" }) end +-- the following Pokemon's commands: none walks behind the player here, so +-- "is it in the way" answers 0 and waiting for it is immediate +L.scrcmd_596 = function(ins, s) emit(s, { "g4_set_var", ins.args[1], 0 }) end +L.scrcmd_600 = function(_, s) emit(s, { "g4_noop", "follower wait" }) end +-- `ScreenShake ampX, ampY, count, period` (HGSS): the look-at target shakes +L.screenshake = function(ins, s) + emit(s, { "g4_screen_shake", ins.args[1], ins.args[2], ins.args[3], ins.args[4] }) +end +L.partyselectui = function(_, s) emit(s, { "g4_select_tutor_mon" }) end +L.scrcmd_656 = function(ins, s) emit(s, { "g4_tutor_has_moves", ins.args[1], 3, ins.args[2] }) end +L.monhasmove = function(ins, s) emit(s, { "g4_mon_has_move", ins.args[1], ins.args[2], ins.args[3] }) end +L.countmonmoves = function(ins, s) emit(s, { "g4_mon_move_count", ins.args[1], ins.args[2] }) end +L.pokemonsummaryscreen = function(ins, s) emit(s, { "g4_tutor_forget_menu", ins.args[2], ins.args[3] }) end +L.getmoveselection = function(ins, s) emit(s, { "g4_tutor_forget_slot", ins.args[2] }) end +L.setmonmove = function(ins, s) emit(s, { "g4_tutor_set_move", ins.args[1], ins.args[3], ins.args[2] }) end -- THE FIVE ROWS THAT HAND OVER THE FIRST POKEMON, and three of them were -- unlowered. Route 201's briefcase scene, in full: @@ -1311,6 +1502,25 @@ end -- the tree. This is the line that opens it. L.startchoosestarterscene = function(_, s) emit(s, { "g4_choose_starter" }) end L.savechosenstarter = function(_, s) emit(s, { "g4_save_starter" }) end +-- Black / White's gift box (Gen5ScriptLink 0x153): answers 0..2 into a var +L.bwchoosestarter = function(ins, s) emit(s, { "g4_bw_choose_starter", ins.args[1] }) end +-- 0x101 : is that Pokemon at full HP (or an Egg) +L.bwmonhpfull = function(ins, s) emit(s, { "g4_mon_hp_full", ins.args[1], ins.args[2] }) end +-- 0x110 : does that Pokemon carry a nickname +L.bwmonhasnickname = function(ins, s) emit(s, { "g4_mon_has_nickname", ins.args[1], ins.args[2] }) end +-- 0x17D: Juniper's catching demonstration on Route 1 +L.bwcatchingtutorial = function(_, s) emit(s, { "g4_bw_catching_tutorial" }) end +-- 0x0D0 / 0x0D4 : today, and the DS profile's birthday +L.bwgetdate = function(ins, s) emit(s, { "g4_bw_date", ins.args[1], ins.args[2] }) end +L.bwgetbirthday = function(ins, s) emit(s, { "g4_bw_birthday", ins.args[1], ins.args[2] }) end +-- 0x0E0 : the version id (21 Black, 20 White) +L.bwgetversion = function(ins, s) emit(s, { "g4_bw_version", ins.args[1] }) end +-- 0x025 : a flag's state into a var +L.bwflagtovar = function(ins, s) emit(s, { "g4_bw_flag_to_var", ins.args[1], ins.args[2] }) end +-- the shared trainer script: beaten?, healer / prize, object movement type +L.bwtrainerbeaten = function(ins, s) emit(s, { "g4_bw_trainer_beaten", ins.args[1], ins.args[2] }) end +L.bwtrainerafter = function(ins, s) emit(s, { "g4_bw_trainer_after", ins.args[1], ins.args[2] }) end +L.bwobjectmovement = function(ins, s) emit(s, { "g4_bw_object_movement", ins.args[1], ins.args[2] }) end -- `givepokemon ` -- the first three are -- var-or-literal, which matters because the starter arrives as a var. L.givepokemon = function(ins, s) @@ -3075,6 +3285,12 @@ L["29f"] = function(_, s) emit(s, { "g4_noop", "a cartridge no-op (scrcmd 29F)" L.cleargame = function(_, s) emit(s, { "g4_clear_game" }) end +-- HEARTGOLD / SOULSILVER: `HOFCredits ` is that ClearGame +-- (CallTask_GameClear); `HallOfFameAnim ` the machine's balls lighting +-- one per Pokemon (ov02_0224CDB0 on prop 0x26), Platinum's healing +-- animation, likewise a picture +L.hofcredits = function(ins, s) emit(s, { "g4_clear_game", ins.args[1] }) end +L.halloffameanim = function(_, s) emit(s, { "g4_noop", "the Hall of Fame machine's balls" }) end L.playhalloffamehealinganimation = function(_, s) emit(s, { "g4_noop", "the Hall of Fame healing animation" }) end diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 522bd6626..76a7b8c18 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -733,7 +733,22 @@ local function pickTargetAndUse(game, battle, id, list) if def and def.machine then opts.tmhm = { move = def.machine.move, kind = def.machine.kind } end - require("src.ui.Screens").push(game, "PartyMenu", opts) + -- PLATINUM'S BATTLE PARTY LIST picks the target on the bottom screen when + -- the battle says it is the party screen there (BattleState: + -- gen4ItemTargetScreen); it asks for the move itself (its RESTORE WHICH + -- MOVE? page) and hands it to `onMove`. + local screenId = "PartyMenu" + if battle and type(battle.gen4ItemTargetScreen) == "function" and not opts.tmhm then + local okS, sid = pcall(battle.gen4ItemTargetScreen, battle) + if okS and sid then + screenId = sid + opts.mode, opts.itemId = "useItem", id + if wantsMove then + opts.onMove = function(mon, mi) useOn(game, battle, id, mon, list, mi) end + end + end + end + require("src.ui.Screens").push(game, screenId, opts) end -- TEXT_BANK_BAG, line 8 of generated/text_banks.txt (zero-based 7), and the diff --git a/src/ui/Gen4BagMenu.lua b/src/ui/Gen4BagMenu.lua index f2b83dafe..946ed2907 100644 --- a/src/ui/Gen4BagMenu.lua +++ b/src/ui/Gen4BagMenu.lua @@ -51,8 +51,15 @@ -- the touch screen. Each is optional, and a missing one leaves its element -- out rather than drawing a likeness. -- --- WHAT AN ITEM DOES IS NOT REIMPLEMENTED HERE: using, giving and tossing live --- in `BagMenu.useItem`, which Emerald's bag calls too. +-- A ON AN ITEM opens the cartridge's action menu (MakeItemActionsMenu: USE / +-- WALK / CHECK / OPEN / PLANT, GIVE, TRASH, REGISTER / DESELECT, CHECK TAG, +-- CANCEL by pocket and item) in its own window, with "X is selected." -- or, +-- in the TM pocket, the move's stats -- and TRASH asks how many and whether +-- it is OK as the cartridge does. SELECT picks an item up and the moving bar +-- puts it down (Item_MoveInPocket). The touch screen's pocket buttons, dial +-- button (A) and dial (a turn of the ring scrolls) take taps through +-- SecondScreen.toLocal. WHAT AN ITEM DOES IS NOT REIMPLEMENTED HERE: USE +-- and GIVE are `BagMenu.useItem` / `giveItem`, which Emerald's bag calls too. local Bag = require("src.inventory.Bag") local Font = require("src.render.Font") @@ -186,6 +193,25 @@ function Gen4BagMenu:ink(which) Font.pushStyle({ text = pair[1], shadow = pair[2] }) end +-- THE CARTRIDGE'S SOUND EFFECTS (bag/main.c and menu.c Sound_PlayEffect), by +-- their SDAT names: SE_CONFIRM_sseq_3 is SEQ_SE_DP_SELECT. +local function se(self, name, opts) + local data = self.game and self.game.data + local ok, Sound = pcall(require, "src.core.Sound") + if ok and type(Sound) == "table" and Sound.playSe and data then return Sound.playSe(data, name, opts) end +end + +-- ItemListMenuCursorCB (main.c 1090-1100): every move of the list's cursor, +-- pad or dial, rustles the bag with the next of three sounds in turn +local GASA = { "SEQ_SE_DP_GASA01", "SEQ_SE_DP_GASA02", "SEQ_SE_DP_GASA03" } +function Gen4BagMenu:listStep(down) + if not self:moveCursor(down) then return false end + local i = self.cursorSoundIdx or 0 + se(self, GASA[i + 1]) + self.cursorSoundIdx = (i + 1) % 3 + return true +end + function Gen4BagMenu:uiSize() return W, H end function Gen4BagMenu:wantsFillScale() return true end function Gen4BagMenu:wantsEdgeBleed() return false end @@ -436,6 +462,10 @@ end function Gen4BagMenu:movePocket(delta) if self.lockPocket then return end + -- CheckPocketChange_DPad: nothing at all in a one-pocket bag, else the + -- pocket swish (main.c 1293/1315) + if #self.pockets <= 1 then return end + se(self, "SEQ_SE_DP_BAG_030") self.cursors[self.pocket] = { self.listPos, self.cursorPos } self.pocket = (self.pocket - 1 + delta) % #self.pockets + 1 local saved = self.cursors[self.pocket] or { 0, 1 } @@ -449,8 +479,11 @@ end function Gen4BagMenu:choose() if self.closed then return end local row = self:selected() + if row and row.header then return end + -- ProcessItemListMenuInput: an item or CLOSE BAG, chosen by A or the dial's + -- button, says SE_CONFIRM (main.c 1265/1271) + se(self, "SEQ_SE_DP_SELECT") if not row or row.close then return self:close() end - if row.header then return end -- A bag opened to ANSWER A QUESTION hands the answer back and closes. if self.pick then @@ -499,7 +532,7 @@ function Gen4BagMenu:pocketType() return self.pocket - 1 end -- TM pocket -- TRASH unless it is too important to part with; REGISTER or -- DESELECT for a registrable item; CANCEL last. function Gen4BagMenu:itemActions(id) - local def = (self.game.data.items or {})[id] or {} + local def = ((self.game.data or {}).items or {})[id] or {} local numeric = tonumber(def.id) or tonumber(id) local pocket = self:pocketType() local out = {} @@ -553,6 +586,8 @@ function Gen4BagMenu:menuStep(delta, byDial) end if i == m.index then return false end m.index = i + -- TryMovingCursorAndPlaySound (menu.c 220), pad and dial alike + se(self, "SEQ_SE_DP_SELECT") -- the pad turns the dial with the cursor; the dial already turned itself if not byDial then self:rotateDial(delta < 0 and 18 or -18) end return true @@ -579,7 +614,7 @@ function Gen4BagMenu:runAction(action) elseif action == "checkTag" then -- BAG_EXIT_CODE_CHECK_BERRY_TAG leaves for the berry's tag, which this -- port does not have: its words are the description, shown in the box - local def = (self.game.data.items or {})[id] or {} + local def = ((self.game.data or {}).items or {})[id] or {} self:say(def.description or "") else self:useSelected(id) @@ -615,7 +650,10 @@ function Gen4BagMenu:trashCount(input) elseif input:wasPressed("down") then tr.n = tr.n - 1; if tr.n <= 0 then tr.n = tr.max end elseif input:wasPressed("left") then tr.n = math.max(1, tr.n - 10) elseif input:wasPressed("right") then tr.n = math.min(tr.max, tr.n + 10) end - if tr.n ~= before then self:rotateDial(tr.n > before and 18 or -18) end + if tr.n ~= before then + self:rotateDial(tr.n > before and 18 or -18) + se(self, "SEQ_SE_DP_BAG_004") -- main.c 2372/2377 + end end function Gen4BagMenu:resolveTrash(yes) @@ -786,6 +824,7 @@ function Gen4BagMenu:shockwave(x, y) self.shock = { x = x, y = y, t = 0 } end function Gen4BagMenu:pressDial() self.dialPress = { t = 0 } self:shockwave(DIAL.x, DIAL.y) + se(self, "SEQ_SE_DP_BUTTON9") -- StepDialButtonPressedAnim step 1, main.c 1689 end -- CheckPocketChange_Touch: the pocket changes, and the button it was runs @@ -813,6 +852,8 @@ function Gen4BagMenu:stepTouchAnims() if pp.t == POCKET_PRESS_AT then local at = (BUTTONS[#self:shownPockets()] or {})[pp.i] if at then self:shockwave(at[1] * 8 + 20, at[2] * 8 + 20) end + -- StepPocketSwitchPressedButtonAnim state 1, frame 3 (main.c 1629) + se(self, "SEQ_SE_DP_BUTTON9") end local held = self.pointer and self.pointer.x and self:pocketButtonAt(self.pointer.x, self.pointer.y) if pp.t >= POCKET_PRESS_DONE and held ~= pp.i then @@ -912,12 +953,16 @@ function Gen4BagMenu:update() return end local m = self.menu + -- Menu_ProcessInput / _ProcessExternalInput: A (or the dial's button) + -- and B both say SE_CONFIRM (menu.c 72/77, 169/173) if input:wasPressed("up") then self:menuStep(-1) elseif input:wasPressed("down") then self:menuStep(1) elseif a then + se(self, "SEQ_SE_DP_SELECT") if m.kind == "yesno" then self:resolveTrash(m.index == 1) else self:runAction(m.rows[m.index]) end elseif input:wasPressed("b") then + se(self, "SEQ_SE_DP_SELECT") if m.kind == "yesno" then self:resolveTrash(false) else self.menu = nil end end return @@ -928,14 +973,21 @@ function Gen4BagMenu:update() if self:stepDial(18) then self:drainDial(function(up) local tr = self.trash + local before = tr.n if up then tr.n = tr.n + 1; if tr.n > tr.max then tr.n = 1 end else tr.n = tr.n - 1; if tr.n <= 0 then tr.n = tr.max end end + -- CheckDialItemAmountChange: only a count that changed (main.c 3115/3129) + if tr.n ~= before then se(self, "SEQ_SE_DP_BAG_004") end return true end) return end - if a then self:confirmTrash() - elseif input:wasPressed("b") then self.trash = nil + -- ProcessItemCountInput_TrashCount: A and B say SE_CONFIRM (main.c + -- 2383/2390); the dial's button goes on with only its own BUTTON9 + if a then + if input:wasPressed("a") then se(self, "SEQ_SE_DP_SELECT") end + self:confirmTrash() + elseif input:wasPressed("b") then se(self, "SEQ_SE_DP_SELECT"); self.trash = nil else self:trashCount(input) end return end @@ -944,28 +996,31 @@ function Gen4BagMenu:update() -- put it down, B puts it back (A on CLOSE BAG moves it to the end) if self.moving then if self:stepDial(36) then - self:drainDial(function(up) return self:moveCursor(not up) end) + self:drainDial(function(up) return self:listStep(not up) end) return end + -- ProcessItemListInput_MovingItem: the dial's button, SELECT, A and B + -- all say SE_CONFIRM (main.c 1826/1834/1861/1871) if input:wasPressed("up") then - if self:moveCursor(false) then self:rotateDial(36) end + if self:listStep(false) then self:rotateDial(36) end elseif input:wasPressed("down") then - if self:moveCursor(true) then self:rotateDial(-36) end - elseif a or input:wasPressed("select") then self:placeMoving(false) - elseif input:wasPressed("b") then self:placeMoving(true) end + if self:listStep(true) then self:rotateDial(-36) end + elseif a or input:wasPressed("select") then se(self, "SEQ_SE_DP_SELECT"); self:placeMoving(false) + elseif input:wasPressed("b") then se(self, "SEQ_SE_DP_SELECT"); self:placeMoving(true) end return end if self:stepDial(36) then - self:drainDial(function(up) return self:moveCursor(not up) end) + self:drainDial(function(up) return self:listStep(not up) end) return end if input:wasPressed("select") and self:canMove() then + se(self, "SEQ_SE_DP_SELECT") -- main.c 1226 self:startMoving() elseif input:wasPressed("up") then - if self:moveCursor(false) then self:rotateDial(36) end + if self:listStep(false) then self:rotateDial(36) end elseif input:wasPressed("down") then - if self:moveCursor(true) then self:rotateDial(-36) end + if self:listStep(true) then self:rotateDial(-36) end elseif input:wasPressed("left") then self.pocketLit = nil self:movePocket(-1) @@ -975,6 +1030,7 @@ function Gen4BagMenu:update() elseif a then self:choose() elseif input:wasPressed("b") or input:wasPressed("start") then + se(self, "SEQ_SE_DP_SELECT") -- MENU_CANCEL, main.c 1265 self:close() end end @@ -1287,7 +1343,7 @@ end -- type and category icons at (64, 152) and (168, 152) -- the summary's own -- type sheet, which is the same pl_batt_obj art in the same palette rows function Gen4BagMenu:drawTMStats(id) - local def = (self.game.data.items or {})[id] or {} + local def = ((self.game.data or {}).items or {})[id] or {} local machine = def.machine local move = machine and machine.move and (self.game.data.moves or {})[machine.move] if type(move) ~= "table" then return end diff --git a/src/ui/Gen4BattleBag.lua b/src/ui/Gen4BattleBag.lua new file mode 100644 index 000000000..fa374a966 --- /dev/null +++ b/src/ui/Gen4BattleBag.lua @@ -0,0 +1,630 @@ +-- src/ui/Gen4BattleBag.lua +-- +-- PLATINUM'S BATTLE BAG -- the BOTTOM-SCREEN bag BAG opens in a battle +-- (src/battle_sub_menus/battle_bag*.c), drawn while the battle stays on the +-- top screen. Three screens, all on b_bag_gra's art (Gen4BattleArt bbag_*): +-- +-- MENU the four battle pockets -- HP/PP RESTORE, STATUS HEALERS, +-- POKe BALLS, BATTLE ITEMS -- the "Item used last" bar and CANCEL +-- POCKET six items a page, the page arrows and "n/m", the pocket's name +-- USE one item: name, "xN", description, USE / CANCEL +-- +-- Everything placed here is the cartridge's number, out of the decomp: +-- buttons sButtonDimensions (pocket buttons (0,1) (0,10) (16,1) (16,10) +-- 16x9; Item used last (0,19) 26x5; CANCEL (27,19); items (0,1) +-- (16,1) (0,7) (16,7) (0,13) (16,13) 16x6; prev (0,19) next +-- (5,19); USE (0,19)), in tiles after SetupBackgroundScroll +-- windows sMenuScreenWindowTemplates / sPocketMenuScreenWindowTemplates / +-- useItemScreenWindowTemplates, FONT_SUBSCREEN TEXT_COLOR(3,2,1) +-- on palette 0, FONT_SYSTEM TEXT_COLOR(1,2,0) on palette 4 +-- sprites item icons at (44|172, 45|93|141), the last used item's at +-- (24, 178), the chosen item's at (40, 44) +-- touch sMenuTouchRects / sPocketMenuTouchRects / sUseItemMenuTouchRects +-- cursor menuScreenCursorPositions / pocketMenuScreenCursorPositions / +-- useItemScreenCursorPositions (src/ui/Gen4SubMenuCursor.lua) +-- pockets BattleBag_Init: every bag slot, field pocket by field pocket, is +-- filed in each battle pocket its ITEM_PARAM_BATTLE_POCKET mask +-- names (bit 0 balls, 1 battle items, 2 HP, 3 status, 4 PP) +-- paging six a page, pages wrap (BattleBagTask_ChangePocketPage) +-- presses BattleBagButtons_Tick: PRESSING then PRESSED (the button's +-- words and icon 4 then 2 px up), then the next screen +-- errors TryUseItem's: two foes, a foe out of sight, a foe behind a +-- substitute, no room in the PC -- battle_bag bank 2 lines 44..48 +-- -- in the message box at window (2,19) 27x4 +-- +-- The bag cursor (pocket, page and position per pocket, the last item used) +-- lives on the game like the cartridge's BagCursor, which outlives a battle. +-- +-- USING an item is the port's own item flow (src/ui/BagMenu.lua useItem), so +-- a POTION here does exactly what it does from the field bag; the target is +-- picked on the battle party list's USE ON WHICH POKeMON? page. + +local Strings = require("src.core.Strings") +local Cursor = require("src.ui.Gen4SubMenuCursor") + +local Gen4BattleBag = {} +Gen4BattleBag.__index = Gen4BattleBag +Gen4BattleBag.isOpaque = false + +Gen4BattleBag.BANK = 2 +Gen4BattleBag.TEXT = { + hpPP = 0, restore = 1, status = 2, healers = 3, battleItems = 6, pokeBalls = 7, + usedLast = 8, pocketHPPP = 22, pocketRestore = 23, pocketStatus = 24, + pocketHealers = 25, pocketBalls = 26, pocketBattleItems = 27, use = 31, + noUse = 34, twoPokemon = 44, noRoom = 45, hidden = 47, substitute = 48, +} + +-- battle pocket indices (ITEM_BATTLE_CATEGORY_*), 0-based as the cartridge's +Gen4BattleBag.POCKET = { HP = 0, STATUS = 1, BALLS = 2, BATTLE = 3 } +-- the mask bit -> pocket (sBattlePocketIndexes) +Gen4BattleBag.MASK_POCKET = { [0] = 2, 3, 0, 1, 0 } +Gen4BattleBag.PER_PAGE = 6 + +Gen4BattleBag.MENU_TOUCH = { + { 8, 79, 0, 127 }, { 80, 151, 0, 127 }, { 8, 79, 128, 255 }, + { 80, 151, 128, 255 }, { 152, 191, 0, 207 }, { 152, 191, 216, 255 }, +} +Gen4BattleBag.POCKET_TOUCH = { + { 8, 55, 0, 127 }, { 8, 55, 128, 255 }, { 56, 103, 0, 127 }, + { 56, 103, 128, 255 }, { 104, 151, 0, 127 }, { 104, 151, 128, 255 }, + { 152, 191, 0, 39 }, { 152, 191, 40, 79 }, { 152, 191, 216, 255 }, +} +Gen4BattleBag.USE_TOUCH = { { 152, 191, 0, 207 }, { 152, 191, 216, 255 } } + +local P = function(n) return { prev = n } end +Gen4BattleBag.MENU_CURSOR = { + { 8, 16, 120, 72, up = 1, down = 2, left = 1, right = 3 }, + { 8, 88, 120, 144, up = 1, down = 5, left = 2, right = 4 }, + { 136, 16, 248, 72, up = 3, down = 4, left = 1, right = 3 }, + { 136, 88, 248, 144, up = 3, down = 6, left = 2, right = 4 }, + { 8, 160, 200, 184, up = 2, down = 5, left = 5, right = 6 }, + { 224, 160, 248, 184, up = P(4), down = 6, left = 5, right = 6 }, +} +Gen4BattleBag.POCKET_CURSOR = { + { 8, 16, 120, 48, up = 1, down = 3, left = 1, right = 2 }, + { 136, 16, 248, 48, up = 2, down = 4, left = 1, right = 2 }, + { 8, 64, 120, 96, up = 1, down = 5, left = 3, right = 4 }, + { 136, 64, 248, 96, up = 2, down = 6, left = 3, right = 4 }, + { 8, 112, 120, 144, up = 3, down = P(7), left = 5, right = 6 }, + { 136, 112, 248, 144, up = 4, down = 9, left = 5, right = 6 }, + { 8, 160, 32, 184, up = 5, down = 7, left = 7, right = 8 }, + { 48, 160, 72, 184, up = 5, down = 8, left = 7, right = 9 }, + { 224, 160, 248, 184, up = 6, down = 9, left = 8, right = 9 }, +} +Gen4BattleBag.USE_CURSOR = { + { 8, 160, 200, 184, up = 1, down = 1, left = 1, right = 2 }, + { 224, 160, 248, 184, up = 2, down = 2, left = 1, right = 2 }, +} + +-- where each button is drawn (pixels) on its screen +Gen4BattleBag.POCKET_BTN_AT = { [0] = { 0, 8 }, { 0, 80 }, { 128, 8 }, { 128, 80 } } +Gen4BattleBag.ITEM_BTN_AT = { { 0, 8 }, { 128, 8 }, { 0, 56 }, { 128, 56 }, { 0, 104 }, { 128, 104 } } +Gen4BattleBag.ITEM_ICON_AT = { { 44, 45 }, { 172, 45 }, { 44, 93 }, { 172, 93 }, { 44, 141 }, { 172, 141 } } +Gen4BattleBag.LAST_ICON_AT = { 24, 178 } +Gen4BattleBag.USE_ICON_AT = { 40, 44 } + +function Gen4BattleBag.available(data) + local art = data and data.gen4_battle_art + return art and art.bbag_menu and art.bbag_pocketbtn_0_0 and art.bbag_item_0_0 and true or false +end + +-- the cartridge's BagCursor: survives the battle, as the field system's does +local function bagCursor(game) + game.gen4BattleBagCursor = game.gen4BattleBagCursor or { + pocket = 0, pages = { [0] = 0, 0, 0, 0 }, positions = { [0] = 0, 0, 0, 0 }, + lastItem = nil, lastPocket = 0, + } + return game.gen4BattleBagCursor +end +Gen4BattleBag.bagCursor = bagCursor + +function Gen4BattleBag.new(game, opts) + opts = opts or {} + local self = setmetatable({}, Gen4BattleBag) + self.game = game + self.battle = opts.battle + self.onCancel = opts.onCancel + self.t = 0 + self.images = {} + local data = game and game.data or {} + self.art = data.gen4_battle_art or {} + local anims = data.gen4_battle_anims or {} + self.ink = anims.bagInk or {} + self.cur = bagCursor(game) + self:build() + -- BattleBag_CanUseLastUsedItem: forget an item no longer carried + if self.cur.lastItem and (tonumber((game.save.inventory or {})[self.cur.lastItem]) or 0) <= 0 then + self.cur.lastItem, self.cur.lastPocket = nil, 0 + end + self.cursor = Cursor.new(self.battle or game, Gen4BattleBag.MENU_CURSOR, (self.cur.pocket or 0) + 1) + self:setScreen("menu") + return self +end + +function Gen4BattleBag:uiSize() + if self.battle and self.battle.uiSize then return self.battle:uiSize() end + return 256, 192 +end + +-- BattleBag_Init: the four battle pockets out of the bag, in field-pocket +-- order and bag order within a pocket. +function Gen4BattleBag:build() + local game = self.game + local Bag = require("src.inventory.Bag") + local items = game.data.items or {} + local order = Bag.order(game.save) or {} + local rank = {} + for i, id in ipairs(order) do rank[id] = i end + local sorted = {} + for _, id in ipairs(order) do sorted[#sorted + 1] = id end + table.sort(sorted, function(a, b) + local da, db = items[a] or {}, items[b] or {} + local pa, pb = tonumber(da.fieldPocket) or 99, tonumber(db.fieldPocket) or 99 + if pa ~= pb then return pa < pb end + return rank[a] < rank[b] + end) + self.pockets = { [0] = {}, {}, {}, {} } + for _, id in ipairs(sorted) do + local def = items[id] + local qty = tonumber((game.save.inventory or {})[id]) or 0 + local mask = def and tonumber(def.battlePocket) or 0 + if qty > 0 and mask > 0 then + for bit = 0, 4 do + if math.floor(mask / 2 ^ bit) % 2 == 1 then + local p = Gen4BattleBag.MASK_POCKET[bit] + local list = self.pockets[p] + local seen = false + for _, row in ipairs(list) do if row.id == id then seen = true end end + if not seen then list[#list + 1] = { id = id, qty = qty, def = def } end + end + end + end + end + self.pageCount = {} + for p = 0, 3 do + local n = #self.pockets[p] + self.pageCount[p] = n == 0 and 0 or math.floor((n - 1) / Gen4BattleBag.PER_PAGE) + if (self.cur.pages[p] or 0) > self.pageCount[p] then self.cur.pages[p] = self.pageCount[p] end + end +end + +-- BattleBag_GetItem: the row at a page position of the current pocket +function Gen4BattleBag:itemAt(pos, pocket) + pocket = pocket or self.pocket or 0 + local row = self.pockets[pocket][(self.cur.pages[pocket] or 0) * Gen4BattleBag.PER_PAGE + pos] + if row and row.qty > 0 then return row end + return nil +end + +function Gen4BattleBag:setScreen(name) + self.screen = name + self.press = nil + if name == "menu" then + self.cursor:set(Gen4BattleBag.MENU_CURSOR, (self.cur.pocket or 0) + 1) + elseif name == "pocket" then + self.cursor:set(Gen4BattleBag.POCKET_CURSOR, (self.cur.positions[self.pocket] or 0) + 1) + else + self.cursor:set(Gen4BattleBag.USE_CURSOR, 1) + end +end + +-- ---------------------------------------------------------------- words -- + +function Gen4BattleBag:text(id, fallback) + local data = self.game and self.game.data + local s = data and data.text and data.text[("TEXT_B%04d_%05d"):format(Gen4BattleBag.BANK, id)] + if type(s) ~= "string" or s == "" then return Strings(fallback or "") end + return (s:gsub("{[^}]*}", "")) +end + +local function rgb(c) + c = c or { 0, 0, 0 } + return { c[1] / 255, c[2] / 255, c[3] / 255, 1 } +end + +function Gen4BattleBag:image(key) + local rec = self.art[key] + local path = type(rec) == "table" and rec.path or rec + if type(path) ~= "string" then return nil end + if self.images[path] == nil then + local ok, img = pcall(require("src.render.Assets").image, path) + self.images[path] = ok and img or false + end + return self.images[path] or nil, rec +end + +function Gen4BattleBag:drawArt(key, x, y) + local img = self:image(key) + if not img then return false end + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, math.floor(x), math.floor(y)) + return true +end + +-- palette 0 TEXT_COLOR(3, 2, 1) or palette 4 TEXT_COLOR(1, 2, 0) +function Gen4BattleBag:words(text, x, y, face, row, a, s) + local Font = require("src.render.Font") + local faced = face and Font.pushFace(face) + local ink = self.ink[row] or {} + local tone = ink[a] and ink[s] and Font.beginTwoTone and Font.beginTwoTone(rgb(ink[a]), rgb(ink[s])) + if not tone then love.graphics.setColor(0, 0, 0, 1) end + Font.draw(text, math.floor(x), math.floor(y)) + if tone then Font.endTwoTone() end + if faced then Font.popFace() end + love.graphics.setColor(1, 1, 1, 1) +end + +function Gen4BattleBag:width(text, face) + local Font = require("src.render.Font") + local faced = face and Font.pushFace(face) + local w = Font.width and Font.width(text) or #text * 8 + if faced then Font.popFace() end + return w +end + +-- PrintTextToWindow: centred in the window +function Gen4BattleBag:centred(text, wx, wy, ww, face, row, a, s) + local w = self:width(text, face) + self:words(text, wx + math.floor((ww - w) / 2), wy, face, row, a, s) +end + +function Gen4BattleBag:itemName(row) + local def = row and (row.def or (self.game.data.items or {})[row.id]) + return def and def.name or tostring(row and row.id or "") +end + +function Gen4BattleBag:drawItemIcon(id, cx, cy) + local def = (self.game.data.items or {})[id] + local n = def and tonumber(def.id) or tonumber(id) + local screens = (self.game.data.gen4_graphics or {}).screens or {} + local rec = n and screens[("items/icon_%03d"):format(n)] + local path = type(rec) == "table" and rec.path or rec + if type(path) ~= "string" then return end + if self.images[path] == nil then + local ok, img = pcall(require("src.render.Assets").image, path) + self.images[path] = ok and img or false + end + local img = self.images[path] + if not img then return end + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, math.floor(cx - img:getWidth() / 2), math.floor(cy - img:getHeight() / 2)) +end + +-- ----------------------------------------------------------------- draw -- + +-- the face a button shows: PRESSING 1, PRESSED 2 for the frames after a press +function Gen4BattleBag:stateOf(name) + local p = self.press + if p and p.name == name then return p.state end + return 0 +end + +-- how far a pressed button's words and icon rise (UpdateWindowScroll / +-- UpdateSpritePositions: -4 pressing, -2 pressed) +local LIFT = { [0] = 0, -4, -2 } + +function Gen4BattleBag:drawMenu() + self:drawArt("bbag_menu", 0, 0) + local T = Gen4BattleBag.TEXT + for p = 0, 3 do + local s = self:stateOf("pocket" .. p) + local at = Gen4BattleBag.POCKET_BTN_AT[p] + self:drawArt(("bbag_pocketbtn_%d_%d"):format(p, s), at[1], at[2]) + end + local function title(p, a, b, x, y) + local dy = LIFT[self:stateOf("pocket" .. p)] + if b then + self:centred(self:text(a[1], a[2]), x, y + 8 + dy, 96, "subscreen", "row0", 3, 2) + self:centred(self:text(b[1], b[2]), x, y + 24 + dy, 96, "subscreen", "row0", 3, 2) + else + self:centred(self:text(a[1], a[2]), x, y + 8 + dy, 96, "subscreen", "row0", 3, 2) + end + end + title(0, { T.hpPP, "HP/PP" }, { T.restore, "RESTORE" }, 16, 32) + title(1, { T.status, "STATUS" }, { T.healers, "HEALERS" }, 16, 104) + title(2, { T.pokeBalls, "POKé BALLS" }, nil, 144, 40) + title(3, { T.battleItems, "BATTLE ITEMS" }, nil, 144, 112) + local last = self.cur.lastItem + local ls = last and self:stateOf("last") or 3 + self:drawArt("bbag_last_" .. ls, 0, 152) + if last then + self:words(self:text(T.usedLast, "Item used last"), 40, 160 + 6 + LIFT[self:stateOf("last")], + "subscreen", "row0", 3, 2) + local at = Gen4BattleBag.LAST_ICON_AT + self:drawItemIcon(last, at[1], at[2] + LIFT[self:stateOf("last")]) + end + self:drawArt("bbag_cancel_" .. self:stateOf("cancel"), 216, 152) +end + +function Gen4BattleBag:drawPocket() + local p = self.pocket or 0 + local T = Gen4BattleBag.TEXT + self:drawArt("bbag_pocket", 0, 0) + for i = 1, 6 do + local row = self:itemAt(i) + local at = Gen4BattleBag.ITEM_BTN_AT[i] + local s = self:stateOf("item" .. i) + self:drawArt(row and ("bbag_item_%d_%d"):format(p, s) or "bbag_item_off", at[1], at[2]) + if row then + local dy = LIFT[s] + -- the name window (1|17, 1|7|13) 14x3, centred at y 8; the amount + -- window (8|24, 4|10|16) 4x3, "x" + count at (0, 4) + local wx, wy = at[1] + 8, at[2] + self:centred(self:itemName(row), wx, wy + 8 + dy, 112, "subscreen", "row0", 3, 2) + self:words("x" .. tostring(row.qty), at[1] + 64, at[2] + 24 + 4 + dy, "system", "row4", 1, 2) + local ic = Gen4BattleBag.ITEM_ICON_AT[i] + self:drawItemIcon(row.id, ic[1], ic[2] + dy) + end + end + local paged = self.pageCount[p] > 0 + self:drawArt("bbag_prev_" .. (paged and self:stateOf("prev") or 3), 0, 152) + self:drawArt("bbag_next_" .. (paged and self:stateOf("next") or 3), 40, 152) + self:drawArt("bbag_cancel_" .. self:stateOf("cancel"), 216, 152) + -- the pocket's name, window (11,19) 10x5, FONT_SYSTEM + local names = { + [0] = { { T.pocketHPPP, "HP/PP" }, { T.pocketRestore, "RESTORE" } }, + { { T.pocketStatus, "STATUS" }, { T.pocketHealers, "HEALERS" } }, + { { T.pocketBalls, "POKé BALLS" } }, + { { T.pocketBattleItems, "BATTLE ITEMS" } }, + } + local lines = names[p] + if #lines == 2 then + self:centred(self:text(lines[1][1], lines[1][2]), 88, 152 + 4, 80, "system", "row4", 1, 2) + self:centred(self:text(lines[2][1], lines[2][2]), 88, 152 + 20, 80, "system", "row4", 1, 2) + else + self:centred(self:text(lines[1][1], lines[1][2]), 88, 152 + 12, 80, "system", "row4", 1, 2) + end + -- "page/pages" (BattleBagText_PrintPocketPageNums): the slash centred in + -- the 32-wide window at (176, 160), the page to its left, the count right + local slash = "/" + local sw = self:width(slash, "system") + local x0 = 176 + math.floor((32 - sw) / 2) + local cur = tostring((self.cur.pages[p] or 0) + 1) + self:words(slash, x0, 164, "system", "row4", 1, 2) + self:words(tostring(self.pageCount[p] + 1), x0 + sw, 164, "system", "row4", 1, 2) + self:words(cur, x0 - self:width(cur, "system"), 164, "system", "row4", 1, 2) +end + +function Gen4BattleBag:drawUse() + local p = self.pocket or 0 + local row = self:itemAt((self.cur.positions[p] or 0) + 1) + self:drawArt("bbag_use_" .. p, 0, 0) + local s = self:stateOf("use") + self:drawArt(("bbag_usebtn_%d_%d"):format(p, s), 0, 152) + self:drawArt("bbag_cancel_" .. self:stateOf("cancel"), 216, 152) + self:centred(self:text(Gen4BattleBag.TEXT.use, "USE"), 80, 160 + 6 + LIFT[s], 48, "subscreen", "row0", 3, 2) + if row then + self:words(self:itemName(row), 56, 32, "system", "row4", 1, 2) + self:words("x" .. tostring(row.qty), 160, 32, "system", "row4", 1, 2) + local def = row.def or {} + local desc = def.description or "" + local Font = require("src.render.Font") + local y = 72 + for line in (desc:gsub("\r", "") .. "\n"):gmatch("(.-)\n") do + self:words(line, 16 + 4, y, "system", "row4", 1, 2) + y = y + (Font.lineHeight and Font.lineHeight() or 16) + end + local at = Gen4BattleBag.USE_ICON_AT + self:drawItemIcon(row.id, at[1], at[2]) + end +end + +function Gen4BattleBag:drawMessage() + if not self.message then return end + local Font = require("src.render.Font") + Font.drawDialogueBox(1, 18, 29, 6) + love.graphics.setColor(0, 0, 0, 1) + local y = 152 + for line in (self.message .. "\n"):gmatch("(.-)\n") do + Font.draw(line, 16, y) + y = y + 16 + end + love.graphics.setColor(1, 1, 1, 1) +end + +function Gen4BattleBag:drawBottom() + -- the backdrop is the sub palette's colour 0 + local back = (self.ink.row0 or {})[0] + if back then + love.graphics.setColor(back[1] / 255, back[2] / 255, back[3] / 255, 1) + love.graphics.rectangle("fill", 0, 0, 256, 192) + love.graphics.setColor(1, 1, 1, 1) + end + if self.screen == "pocket" then self:drawPocket() + elseif self.screen == "use" then self:drawUse() + else self:drawMenu() end + self:drawMessage() + if not self.message then self.cursor:draw(self.battle, self.game, self.t) end +end + +function Gen4BattleBag:draw() + local ok, SecondScreen = pcall(require, "src.ui.SecondScreen") + if not ok then return end + SecondScreen.draw(self.game, function() self:drawBottom() end) + if SecondScreen.drawFrame then SecondScreen.drawFrame(self.game) end +end + +-- ---------------------------------------------------------------- input -- + +function Gen4BattleBag:close() + if self.closed then return end + self.closed = true + local stack = self.game.stack + if stack and stack.top and stack:top() == self then stack:pop() + elseif stack and not stack.top then stack:pop() end + if self.onCancel then self.onCancel() end +end + +-- BattleBagButtons_PressButton: the button shows PRESSING, then PRESSED, and +-- the queued step runs once it is PRESSED (BattleBagTask_ScreenTransition) +function Gen4BattleBag:pressThen(name, fn) + Cursor.sound("decide", self.game.data) + self.press = { name = name, state = 1, ticks = 0, fn = fn } +end + +function Gen4BattleBag:say(text) + self.message = text +end + +function Gen4BattleBag:pickMenu(i) + if i >= 1 and i <= 4 then + local p = i - 1 + return self:pressThen("pocket" .. p, function() + self.pocket = p + self.cur.pocket = p + self:setScreen("pocket") + end) + end + if i == 5 then + local last = self.cur.lastItem + if not last then return end + return self:pressThen("last", function() + -- BattleBag_SetLastUsedPocket: the item's page and position + local p = self.cur.lastPocket or 0 + self.pocket = p + for n, row in ipairs(self.pockets[p]) do + if row.id == last then + self.cur.positions[p] = (n - 1) % Gen4BattleBag.PER_PAGE + self.cur.pages[p] = math.floor((n - 1) / Gen4BattleBag.PER_PAGE) + break + end + end + self:setScreen("use") + end) + end + if i == 6 then + return self:pressThen("cancel", function() self:close() end) + end +end + +function Gen4BattleBag:pickPocket(i) + local p = self.pocket or 0 + if i >= 1 and i <= 6 then + if not self:itemAt(i) then return end + return self:pressThen("item" .. i, function() + self.cur.positions[p] = i - 1 + self:setScreen("use") + end) + end + if i == 7 or i == 8 then + if self.pageCount[p] == 0 then return end + local delta = i == 7 and -1 or 1 + return self:pressThen(i == 7 and "prev" or "next", function() + local page = (self.cur.pages[p] or 0) + delta + if page > self.pageCount[p] then page = 0 elseif page < 0 then page = self.pageCount[p] end + self.cur.pages[p] = page + self.cur.positions[p] = 0 + self.press = nil + end) + end + if i == 9 then + return self:pressThen("cancel", function() self:setScreen("menu") end) + end +end + +-- TryUseItem's refusals, then the port's own item flow +function Gen4BattleBag:refusal(row) + local b = self.battle + local T = Gen4BattleBag.TEXT + if not b then return nil end + if (self.pocket or 0) == Gen4BattleBag.POCKET.BALLS then + local foes = 0 + if b.foesOf and b.player then + local ok, list = pcall(b.foesOf, b, b.player or (b.activeOn and b:activeOn(1))) + foes = ok and #list or 0 + end + if foes > 1 then return self:text(T.twoPokemon, "It's no good! It's impossible to aim\nwhen there are two Pokémon!") end + local foe = b.enemy + if foe and foe.invulnerable then + return self:text(T.hidden, "It's no good! It's impossible to aim\nat a Pokémon that's not in sight!") + end + if foe and (tonumber(foe.substituteHP) or 0) > 0 then + return self:text(T.substitute, "It's no good! It's impossible to aim\nat a Pokémon that's not in sight!") + end + end + return nil +end + +function Gen4BattleBag:use() + local p = self.pocket or 0 + local row = self:itemAt((self.cur.positions[p] or 0) + 1) + if not row then return end + self:pressThen("use", function() + self.press = nil + local why = self:refusal(row) + if why then return self:say((why:gsub("[\v\f]", "\n"))) end + -- BagCursor_SetBattleCurrentCategory / Bag_SetLastBattleItemUsed + self.cur.pocket = p + self.cur.lastItem, self.cur.lastPocket = row.id, p + local shim = { + items = {}, index = 1, + close = function() self:close() end, + rebuild = function() self:build() end, + } + local ok, err = pcall(require("src.ui.BagMenu").useItem, self.game, self.battle, row.id, shim) + if not ok then + local okL, Logger = pcall(require, "src.core.Logger") + if okL and Logger.warn then Logger.warn("gen4 battle bag: %s could not be used: %s", tostring(row.id), tostring(err)) end + end + if not self.closed then self:build() end + end) +end + +function Gen4BattleBag:pickUse(i) + if i == 1 then return self:use() end + if i == 2 then + return self:pressThen("cancel", function() self:setScreen("pocket") end) + end +end + +function Gen4BattleBag:pick(i) + if self.screen == "menu" then return self:pickMenu(i) + elseif self.screen == "pocket" then return self:pickPocket(i) + else return self:pickUse(i) end +end + +function Gen4BattleBag:update(dt) + self.t = (self.t or 0) + (dt or 1 / 60) + local input = self.game.input + local p = self.press + if p then + p.ticks = p.ticks + 1 + if p.state == 1 then p.state = 2 + else + local fn = p.fn + self.press = nil + if fn then fn() end + end + return + end + if self.message then + if input and (input:wasPressed("a") or input:wasPressed("b")) then self.message = nil end + return + end + local r = self.cursor:tick(input) + if r == "back" then + local back = { menu = 6, pocket = 9, use = 2 } + return self:pick(back[self.screen]) + elseif r then + return self:pick(r) + end +end + +function Gen4BattleBag:touchpressed(_, px, py) + local ok, SecondScreen = pcall(require, "src.ui.SecondScreen") + if not ok then return false end + local x, y = SecondScreen.toLocal(self.game, px, py) + if not x then return false end + if self.press then return true end + if self.message then self.message = nil; return true end + local rects = self.screen == "menu" and Gen4BattleBag.MENU_TOUCH + or self.screen == "pocket" and Gen4BattleBag.POCKET_TOUCH or Gen4BattleBag.USE_TOUCH + local i = Cursor.hit(rects, x, y) + if i then + self.cursor:hide() + self:pick(i) + end + return true +end + +return Gen4BattleBag diff --git a/src/ui/Gen4BattleParty.lua b/src/ui/Gen4BattleParty.lua index e1d8164aa..1c87d6cf9 100644 --- a/src/ui/Gen4BattleParty.lua +++ b/src/ui/Gen4BattleParty.lua @@ -2,24 +2,38 @@ -- -- PLATINUM'S BATTLE PARTY LIST -- the BOTTOM-SCREEN screen POKeMON opens in a -- battle (src/battle_sub_menus/battle_party*.c), drawn while the battle stays --- on the top screen. Two of its screens, which are the switch itself: +-- on the top screen. Its screens (enum BattlePartyScreen): -- --- PARTY six panels and CANCEL (BATTLE_PARTY_SCREEN_POKEMON_PARTY) --- SELECT SHIFT / SUMMARY / CHECK MOVES / CANCEL for one member --- (BATTLE_PARTY_SCREEN_SELECT_POKEMON) +-- party six panels and CANCEL (POKEMON_PARTY) +-- select SHIFT / SUMMARY / CHECK MOVES / CANCEL (SELECT_POKEMON) +-- summary level, EXP, stats, ability, held item (POKEMON_SUMMARY) +-- moves the four moves as buttons (POKEMON_MOVES) +-- movesum one move's power, accuracy, category (MOVE_SUMMARY) +-- restore RESTORE WHICH MOVE?, an ETHER's target (RESTORE_MOVE_PP) +-- learn the four moves and the one to learn (LEARN_MOVE) +-- confirm the move to forget, FORGET / CANCEL (CONFIRM_LEARN_MOVE) -- --- SUMMARY and CHECK MOVES hand over to the port's own Gen 4 summary screen --- rather than the cartridge's bottom-screen pages (those are the next piece --- of this screen), with the moves page asked for by CHECK MOVES. +-- and three modes (enum BattlePartyMode): +-- +-- switch (default) pick a Pokemon to SHIFT in +-- useItem "Use on which Pokemon?" -- the bag's target; an ETHER-type +-- item then asks RESTORE WHICH MOVE? (opts.itemId, opts.onMove) +-- learnMove which move to forget for opts.moveId on opts.mon; the answer +-- (1..4, or nil for "don't learn") goes to opts.onChoose -- -- Everything placed here is the cartridge's number, all out of the decomp: -- panels sButtonDimensions, tile (0,0) (16,1) (0,6) (16,7) (0,12) -- (16,13), 16x6; CANCEL (27,19) 5x5; SHIFT (1,1) 30x17; --- SUMMARY (0,19), CHECK MOVES (13,19), 13x5 +-- SUMMARY (0,19), CHECK MOVES (13,19), 13x5; the detail pages' +-- prev (0,19) next (5,19), their SUMMARY / CHECK MOVES at +-- (12,19); move buttons (0,6) (16,6) (0,12) (16,12) 16x6; the +-- move to learn (8,18); FORGET (0,19) 26x5; the move summary's +-- four small move buttons (11|16, 19|21) 5x2 -- panel art RetrieveButtonData: the Pokemon IN battle on the primary -- panel, the rest on the ALT one, an empty slot on ALT -- DISABLED, a fainted one forced onto palette 2, an egg with -- its HP-bar columns blanked +-- page art ScreenBackgroundDataMemberIndexes (Gen4BattleArt b*_bg/_fg) -- slot text sPartyPokemonScreenWindowTemplates (15x5 at the panel's -- tile): name at (32, 8) FONT_SUBSCREEN TEXT_COLOR(7, 8, 9), -- gender FONT_SYSTEM right-aligned at y 8 in (10, 11) / (12, @@ -27,23 +41,26 @@ -- at (64, 24), 48 px, colour 1/3/5 with +1 on its top and -- bottom rows; "Lv" + level at (8, 32) unless a status icon -- is up +-- page text sPokemonSummaryScreenWindowTemplates and the rest, palette +-- 13 (pl_font 6 with row 9's 10..13 copied in) for the labels +-- and values, palette 9 for the buttons' words -- sprites icon centres (16,16) (144,24) (16,64) (144,72) (16,112) -- (144,120); status icons (28,40) .. (156,144); held item at -- icon + (8, 8), mail at icon + (16, 8); the select screen's --- icon at (128, 72) --- cursor sPokemonPartyScreenCursorPositions / --- sSelectPokemonScreenCursorPositions, drawn with the battle --- subscreen's corner cursor --- words bank 3: "Choose a Pokémon." (6), SHIFT (15), SUMMARY (18), --- CHECK MOVES (19), in the message box at window (2, 21) 22x2 --- --- Art: gen4_battle_art's bparty_* / bselect_* (src/import/Gen4BattleArt.lua); --- colours: gen4_battle_anims.partyInk (row 9 of the screen's palette). +-- icon at (128, 72); the detail pages' icon (24,12), status +-- (198,20), types (130,16) (164,16), move types (24|152, +-- 80|128), the move summary's type (136,48) and class (24,88) +-- cursor the sScreenCursorPositions tables, through the shared +-- sub-menu cursor (hidden until a key is pressed, hidden again +-- by a tap -- src/ui/Gen4SubMenuCursor.lua) +-- words bank 3 (battle_party.json) -- --- The interface is Gen4PartyMenu's battle mode, so BattleState hands either --- screen the same options: { battle, onSwitch(mon), forceSwitch, onCancel }. +-- Art: gen4_battle_art's bparty_* / b*_bg / b*_fg (src/import/Gen4BattleArt.lua); +-- colours: gen4_battle_anims.partyInk (row 9) and partyInk13 (palette 13); +-- type and category icons: gen4_summary_art type_* / category_*. local Strings = require("src.core.Strings") +local Cursor = require("src.ui.Gen4SubMenuCursor") local Gen4BattleParty = {} Gen4BattleParty.__index = Gen4BattleParty @@ -52,7 +69,14 @@ Gen4BattleParty.__index = Gen4BattleParty Gen4BattleParty.isOpaque = false Gen4BattleParty.BANK = 3 -Gen4BattleParty.TEXT = { choose = 6, shift = 15, male = 16, female = 17, summary = 18, checkMoves = 19 } +Gen4BattleParty.TEXT = { + choose = 6, useOn = 7, pp = 14, shift = 15, male = 16, female = 17, summary = 18, + checkMoves = 19, noItem = 20, lv = 23, nextLv = 25, hp = 28, slash = 31, + attack = 32, defense = 34, spAtk = 36, spDef = 38, speed = 40, movePP = 43, + power = 48, none = 50, accuracy = 51, category = 53, physical = 54, + status = 55, special = 56, forget = 58, cancel = 59, hm = 60, + restoreWhich = 94, +} Gen4BattleParty.PANEL_TILES = { { 0, 0 }, { 16, 1 }, { 0, 6 }, { 16, 7 }, { 0, 12 }, { 16, 13 }, @@ -69,9 +93,18 @@ Gen4BattleParty.SUMMARY_AT = { 0, 152 } Gen4BattleParty.CHECK_AT = { 104, 152 } Gen4BattleParty.SELECT_ICON = { 128, 72 } Gen4BattleParty.HP_BAR = 48 +Gen4BattleParty.PAGE_ICON = { 24, 12 } +Gen4BattleParty.PAGE_STATUS = { 198, 20 } +Gen4BattleParty.PAGE_TYPES = { { 130, 16 }, { 164, 16 } } +Gen4BattleParty.MOVE_BTN_AT = { { 0, 48 }, { 128, 48 }, { 0, 96 }, { 128, 96 } } +Gen4BattleParty.MOVE_TYPE_AT = { { 24, 80 }, { 152, 80 }, { 24, 128 }, { 152, 128 }, { 88, 176 } } +Gen4BattleParty.LEARN_BTN_AT = { 64, 144 } +Gen4BattleParty.MS_BTN_AT = { { 88, 152 }, { 128, 152 }, { 88, 168 }, { 128, 168 } } + +local P = function(n) return { prev = n } end --- the cursor's corners and its neighbours (index 7 = CANCEL on the party --- screen; 1 SHIFT, 2 SUMMARY, 3 CHECK MOVES, 4 CANCEL on the select one) +-- the cursor tables (1-based; party: 7 = CANCEL; select: 1 SHIFT, 2 SUMMARY, +-- 3 CHECK MOVES, 4 CANCEL) Gen4BattleParty.PARTY_CURSOR = { { 8, 8, 120, 40, up = 7, down = 3, left = 7, right = 2 }, { 136, 16, 248, 48, up = 5, down = 4, left = 1, right = 3 }, @@ -82,12 +115,57 @@ Gen4BattleParty.PARTY_CURSOR = { { 224, 160, 248, 184, up = 6, down = 1, left = 6, right = 1 }, } Gen4BattleParty.SELECT_CURSOR = { - { 16, 16, 240, 136, up = 1, down = "previous", left = 1, right = 1 }, + { 16, 16, 240, 136, up = 1, down = P(2), left = 1, right = 1 }, { 8, 160, 96, 184, up = 1, down = 2, left = 2, right = 3 }, { 112, 160, 200, 184, up = 1, down = 3, left = 2, right = 4 }, { 224, 160, 248, 184, up = 1, down = 4, left = 3, right = 4 }, } --- the touch rects (sPartyPokemonScreenTouchRects / sSelectPokemonScreenTouchRects) +Gen4BattleParty.SUMMARY_CURSOR = { + { 8, 160, 32, 184, up = 1, down = 1, left = 1, right = 2 }, + { 48, 160, 72, 184, up = 2, down = 2, left = 1, right = 3 }, + { 104, 160, 192, 184, up = 3, down = 3, left = 2, right = 4 }, + { 224, 160, 248, 184, up = 4, down = 4, left = 3, right = 4 }, +} +Gen4BattleParty.MOVES_CURSOR = { + { 8, 56, 120, 88, up = 1, down = 3, left = 1, right = 2 }, + { 136, 56, 248, 88, up = 2, down = 4, left = 1, right = 2 }, + { 8, 104, 120, 136, up = 1, down = P(5), left = 3, right = 4 }, + { 136, 104, 248, 136, up = 2, down = P(8), left = 3, right = 4 }, + { 8, 160, 32, 184, up = 3, down = 5, left = 5, right = 6 }, + { 48, 160, 72, 184, up = 3, down = 6, left = 5, right = 7 }, + { 104, 160, 192, 184, up = 4, down = 7, left = 6, right = 8 }, + { 224, 160, 248, 184, up = 4, down = 8, left = 7, right = 8 }, +} +Gen4BattleParty.MOVESUM_CURSOR = { + { 92, 157, 124, 165, up = 1, down = 3, left = 1, right = 2 }, + { 132, 157, 164, 165, up = 2, down = 4, left = 1, right = 5 }, + { 92, 173, 124, 181, up = 1, down = 3, left = 3, right = 4 }, + { 132, 173, 164, 181, up = 2, down = 4, left = 3, right = 5 }, + { 224, 160, 248, 184, up = 5, down = 5, left = P(4), right = 5 }, +} +Gen4BattleParty.RESTORE_CURSOR = { + { 8, 56, 120, 88, up = 1, down = 3, left = 1, right = 2 }, + { 136, 56, 248, 88, up = 2, down = 4, left = 1, right = 2 }, + { 8, 104, 120, 136, up = 1, down = 5, left = 3, right = 4 }, + { 136, 104, 248, 136, up = 2, down = 5, left = 3, right = 4 }, + { 224, 160, 248, 184, up = P(4), down = 5, left = 5, right = 5 }, +} +Gen4BattleParty.LEARN_CURSOR = { + { 8, 56, 120, 88, up = 6, down = 3, left = 1, right = 2 }, + { 136, 56, 248, 88, up = 6, down = 4, left = 1, right = 2 }, + { 8, 104, 120, 136, up = 1, down = 5, left = 3, right = 4 }, + { 136, 104, 248, 136, up = 2, down = 7, left = 3, right = 4 }, + { 72, 152, 184, 184, up = 3, down = 5, left = 5, right = 7 }, + { 192, 8, 248, 24, up = 6, down = P(2), left = 1, right = 6 }, + { 224, 160, 248, 184, up = 4, down = 7, left = 5, right = 7 }, +} +Gen4BattleParty.CONFIRM_CURSOR = { + { 8, 160, 200, 184, up = 2, down = 1, left = 1, right = 3 }, + { 192, 8, 248, 24, up = 2, down = P(3), left = 1, right = 2 }, + { 224, 160, 248, 184, up = 2, down = 3, left = 1, right = 3 }, +} + +-- the touch rects { top, bottom, left, right } (s*ScreenTouchRects) Gen4BattleParty.PARTY_TOUCH = { { 0, 47, 0, 127 }, { 8, 55, 128, 255 }, { 48, 95, 0, 127 }, { 56, 103, 128, 255 }, { 96, 143, 0, 127 }, { 104, 151, 128, 255 }, @@ -96,6 +174,40 @@ Gen4BattleParty.PARTY_TOUCH = { Gen4BattleParty.SELECT_TOUCH = { { 8, 143, 8, 247 }, { 152, 191, 0, 103 }, { 152, 191, 104, 207 }, { 152, 191, 216, 255 }, } +Gen4BattleParty.SUMMARY_TOUCH = { + { 152, 191, 0, 39 }, { 152, 191, 40, 79 }, { 152, 191, 96, 199 }, { 152, 191, 216, 255 }, +} +Gen4BattleParty.MOVES_TOUCH = { + { 48, 95, 0, 127 }, { 48, 95, 128, 255 }, { 96, 143, 0, 127 }, { 96, 143, 128, 255 }, + { 152, 191, 0, 39 }, { 152, 191, 40, 79 }, { 152, 191, 96, 199 }, { 152, 191, 216, 255 }, +} +Gen4BattleParty.MOVESUM_TOUCH = { + { 152, 167, 88, 127 }, { 152, 167, 128, 167 }, { 168, 183, 88, 127 }, { 168, 183, 128, 167 }, + { 152, 191, 216, 255 }, +} +Gen4BattleParty.RESTORE_TOUCH = { + { 48, 95, 0, 127 }, { 48, 95, 128, 255 }, { 96, 143, 0, 127 }, { 96, 143, 128, 255 }, + { 152, 191, 216, 255 }, +} +Gen4BattleParty.LEARN_TOUCH = { + { 48, 95, 0, 127 }, { 48, 95, 128, 255 }, { 96, 143, 0, 127 }, { 96, 143, 128, 255 }, + { 144, 191, 64, 191 }, { 0, 39, 184, 255 }, { 152, 191, 216, 255 }, +} +Gen4BattleParty.CONFIRM_TOUCH = { + { 152, 191, 0, 207 }, { 0, 39, 184, 255 }, { 152, 191, 216, 255 }, +} + +local SCREENS = { + party = { "PARTY_CURSOR", "PARTY_TOUCH", back = 7 }, + select = { "SELECT_CURSOR", "SELECT_TOUCH", back = 4 }, + summary = { "SUMMARY_CURSOR", "SUMMARY_TOUCH", back = 4 }, + moves = { "MOVES_CURSOR", "MOVES_TOUCH", back = 8 }, + movesum = { "MOVESUM_CURSOR", "MOVESUM_TOUCH", back = 5 }, + restore = { "RESTORE_CURSOR", "RESTORE_TOUCH", back = 5 }, + learn = { "LEARN_CURSOR", "LEARN_TOUCH", back = 7 }, + confirm = { "CONFIRM_CURSOR", "CONFIRM_TOUCH", back = 3 }, +} +Gen4BattleParty.SCREENS = SCREENS function Gen4BattleParty.new(game, opts) opts = opts or {} @@ -104,15 +216,31 @@ function Gen4BattleParty.new(game, opts) self.battle = opts.battle self.onSwitch = opts.onSwitch self.onCancel = opts.onCancel + self.onMove = opts.onMove + self.onChoose = opts.onChoose self.forceSwitch = opts.forceSwitch - self.screen = "party" + self.mode = opts.mode or "switch" + self.itemId = opts.itemId + self.moveId = opts.moveId self.index = 1 self.selectIndex = 1 + self.moveSlot = 1 self.t = 0 self.images = {} local data = game and game.data or {} self.art = data.gen4_battle_art or {} - self.ink = (data.gen4_battle_anims or {}).partyInk or {} + self.typeArt = data.gen4_summary_art or {} + local anims = data.gen4_battle_anims or {} + self.ink = anims.partyInk or {} + self.ink13 = anims.partyInk13 or self.ink + self.cursor = Cursor.new(self.battle or game, Gen4BattleParty.PARTY_CURSOR, 1) + if self.mode == "learnMove" and opts.mon then + for i, m in ipairs(self:party()) do if m == opts.mon then self.index = i end end + self.learner = opts.mon + self:setScreen("learn") + else + self:setScreen("party") + end return self end @@ -122,6 +250,12 @@ function Gen4BattleParty.available(data) return art and art.bparty_bg and art.bparty_panel_0_0 and true or false end +-- ...and its detail pages (an older cache has the list but not these) +function Gen4BattleParty.pagesAvailable(data) + local art = data and data.gen4_battle_art + return art and art.bsummary_bg and art.bmoves_bg and art.bparty_movebtn_0 and true or false +end + -- The battle's own surface, so the screen draws in it untranslated. function Gen4BattleParty:uiSize() if self.battle and self.battle.uiSize then return self.battle:uiSize() end @@ -129,6 +263,7 @@ function Gen4BattleParty:uiSize() end function Gen4BattleParty:party() return (self.game.save and self.game.save.party) or {} end +function Gen4BattleParty:mon() return self.learner or self:party()[self.index] end local function isEgg(mon) return require("src.pokemon.Party").isEgg(mon) end @@ -139,15 +274,19 @@ function Gen4BattleParty:text(id, fallback) return (s:gsub("{[^}]*}", "")) end -function Gen4BattleParty:image(key) - local rec = self.art[key] - local path = type(rec) == "table" and rec.path or rec +local function loadImage(self, path) if type(path) ~= "string" then return nil end if self.images[path] == nil then local ok, img = pcall(require("src.render.Assets").image, path) self.images[path] = ok and img or false end - return self.images[path] or nil, rec + return self.images[path] or nil +end + +function Gen4BattleParty:image(key) + local rec = self.art[key] + local path = type(rec) == "table" and rec.path or rec + return loadImage(self, path), rec end local function rgb(c, fallback) @@ -164,6 +303,16 @@ function Gen4BattleParty:drawArt(key, x, y) return true end +-- a gen4_summary_art type_ / category_ sprite at its position +function Gen4BattleParty:drawTypeArt(key, x, y) + local rec = self.typeArt[key] + local img = loadImage(self, type(rec) == "table" and rec.path or rec) + if not img then return false end + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, math.floor(x + (rec.originX or 0)), math.floor(y + (rec.originY or 0))) + return true +end + -- Is this member the one standing in the battle (or its partner)? function Gen4BattleParty:inBattle(mon) local b = self.battle @@ -194,10 +343,10 @@ local function tone(ink, a, s) return false end -function Gen4BattleParty:drawWords(text, x, y, face, a, s) +function Gen4BattleParty:drawWords(text, x, y, face, a, s, ink) local Font = require("src.render.Font") local faced = face and Font.pushFace(face) - local t = tone(self.ink, a, s) + local t = tone(ink or self.ink, a, s) Font.draw(text, math.floor(x), math.floor(y)) if t then Font.endTwoTone() end if faced then Font.popFace() end @@ -212,6 +361,16 @@ local function widthIn(face, text) return w end +-- palette 13 words (labels TEXT_COLOR(15,14,0), values (1,2,0)) +function Gen4BattleParty:label(text, x, y) self:drawWords(text, x, y, "system", 15, 14, self.ink13) end +function Gen4BattleParty:value(text, x, y) self:drawWords(text, x, y, "system", 1, 2, self.ink13) end +function Gen4BattleParty:valueRight(text, x, y, w) + self:value(text, x + w - widthIn("system", text), y) +end +function Gen4BattleParty:labelCentred(text, x, y, w) + self:label(text, x + math.floor((w - widthIn("system", text)) / 2), y) +end + -- the special-character strip: 0..9, "/" at 10, "Lv" at 11-12 function Gen4BattleParty:glyph(n, x, y, w) local img = self:image("bparty_digits") @@ -253,9 +412,29 @@ function Gen4BattleParty.statusSeq(mon) return s and STATUS_SEQ[s] or nil end +local function maxHPOf(mon) return math.max(1, (mon.stats and mon.stats.hp) or mon.maxHp or 1) end + +-- the bar's three rows in palette 9 colour c (1 green, 3 yellow, 5 red) +function Gen4BattleParty:hpBar(mon, x, y) + local max = maxHPOf(mon) + local cur = math.max(0, math.min(max, tonumber(mon.hp) or 0)) + local px = math.floor(cur * Gen4BattleParty.HP_BAR / max) + if px == 0 and cur > 0 then px = 1 end + if px <= 0 then return end + local colour = (px > Gen4BattleParty.HP_BAR / 2 and 1) or (px > Gen4BattleParty.HP_BAR / 5 and 3) or 5 + local ink = self.ink + local edge, body = rgb(ink[colour + 1]), rgb(ink[colour]) + local g = love.graphics + g.setColor(edge); g.rectangle("fill", x, y + 1, px, 1) + g.setColor(body); g.rectangle("fill", x, y + 2, px, 2) + g.setColor(edge); g.rectangle("fill", x, y + 4, px, 1) + g.setColor(1, 1, 1, 1) +end + -- ----------------------------------------------------------------- icons -- -function Gen4BattleParty:drawIcon(mon, cx, cy) +local ICON_TICK = 0.32 +function Gen4BattleParty:drawIcon(mon, cx, cy, still) local data = self.game.data local icons = data.icons local def = data.pokemon and data.pokemon[mon.species] @@ -264,18 +443,13 @@ function Gen4BattleParty:drawIcon(mon, cx, cy) entry = icons.bySpecies[mon.species == 490 and 495 or 494] or entry end local path = type(entry) == "table" and entry.image or entry - if type(path) ~= "string" then return end - if self.images[path] == nil then - local ok, img = pcall(require("src.render.Assets").image, path) - self.images[path] = ok and img or false - end - local img = self.images[path] + local img = loadImage(self, path) if not img then return end local iw, ih = img:getDimensions() local fh = math.min(ih, tonumber(type(entry) == "table" and entry.frameHeight) or 32) local frames = math.max(1, math.floor(ih / fh)) local f = 0 - if (tonumber(mon.hp) or 0) > 0 and frames > 1 then f = math.floor(self.t / 0.32) % frames end + if not still and (tonumber(mon.hp) or 0) > 0 and frames > 1 then f = math.floor(self.t / ICON_TICK) % frames end local q = love.graphics.newQuad(0, f * fh, iw, fh, iw, ih) love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(img, q, math.floor(cx - iw / 2), math.floor(cy - fh / 2)) @@ -288,15 +462,13 @@ function Gen4BattleParty:drawHeld(mon, cx, cy) self:drawArt(mail and "bparty_held_mail" or "bparty_held_item", cx + 8, cy + 8) end --- ----------------------------------------------------------------- draw -- +-- ----------------------------------------------------------- party list -- function Gen4BattleParty:drawSlot(slot) local mon = self:party()[slot] local tx, ty = Gen4BattleParty.PANEL_TILES[slot][1] * 8, Gen4BattleParty.PANEL_TILES[slot][2] * 8 self:drawArt(self:panelKey(slot), tx, ty) if not mon then return end - local ink = self.ink - -- the window sits on the panel's own tile, so its (x, y) is the panel's local name = self:nameOf(mon) self:drawWords(name, tx + 32, ty + 8, "subscreen", 7, 8) if not isEgg(mon) then @@ -308,24 +480,12 @@ function Gen4BattleParty:drawSlot(slot) self:drawWords(sym, tx + 120 - w, ty + 8, "system", g == "male" and 10 or 12, g == "male" and 11 or 13) end - -- HP: "cur/max" in the special characters, the bar above it - local max = math.max(1, (mon.stats and mon.stats.hp) or mon.maxHp or 1) + local max = maxHPOf(mon) local cur = math.max(0, math.min(max, tonumber(mon.hp) or 0)) self:number(cur, 3, "spaces", tx + 56, ty + 32) self:glyph(10, tx + 80, ty + 32) self:number(max, 3, "none", tx + 88, ty + 32) - local px = math.floor(cur * Gen4BattleParty.HP_BAR / max) - if px == 0 and cur > 0 then px = 1 end - if px > 0 then - local colour = (px > Gen4BattleParty.HP_BAR / 2 and 1) - or (px > Gen4BattleParty.HP_BAR / 5 and 3) or 5 - local edge, body = rgb(ink[colour + 1]), rgb(ink[colour]) - local g = love.graphics - g.setColor(edge); g.rectangle("fill", tx + 64, ty + 24 + 1, px, 1) - g.setColor(body); g.rectangle("fill", tx + 64, ty + 24 + 2, px, 2) - g.setColor(edge); g.rectangle("fill", tx + 64, ty + 24 + 4, px, 1) - g.setColor(1, 1, 1, 1) - end + self:hpBar(mon, tx + 64, ty + 24) local status = Gen4BattleParty.statusSeq(mon) if status then local at = Gen4BattleParty.STATUS_AT[slot] @@ -340,24 +500,44 @@ function Gen4BattleParty:drawSlot(slot) self:drawHeld(mon, ic[1], ic[2]) end +-- the options frame at window (2, 21) 22x2: "Choose a Pokemon." / "Use on +-- which Pokemon?" / "Restore which move?" +function Gen4BattleParty:drawPrompt(text) + local Font = require("src.render.Font") + Font.drawDialogueBox(1, 20, 24, 4) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(text, 16, 168) + love.graphics.setColor(1, 1, 1, 1) +end + +-- the error box at window (2, 19) 27x4 (BattlePartyText_DisplayErrorMessage) +function Gen4BattleParty:drawMessage() + if not self.message then return end + local Font = require("src.render.Font") + Font.drawDialogueBox(1, 18, 29, 6) + love.graphics.setColor(0, 0, 0, 1) + local y = 152 + for line in (self.message .. "\n"):gmatch("(.-)\n") do + Font.draw(line, 16, y); y = y + 16 + end + love.graphics.setColor(1, 1, 1, 1) +end + function Gen4BattleParty:drawParty() self:drawArt("bparty_bg", 0, 0) self:drawArt("bparty_fg", 0, 0) for slot = 1, 6 do self:drawSlot(slot) end self:drawArt(self.forceSwitch and "bparty_cancel_3" or "bparty_cancel_0", Gen4BattleParty.CANCEL_AT[1], Gen4BattleParty.CANCEL_AT[2]) - -- "Choose a Pokémon." in the options frame, window (2, 21) 22x2 - local Font = require("src.render.Font") - Font.drawDialogueBox(1, 20, 24, 4) - love.graphics.setColor(0, 0, 0, 1) - Font.draw(self:text(Gen4BattleParty.TEXT.choose, "Choose a POKéMON."), 16, 168) - love.graphics.setColor(1, 1, 1, 1) - local c = Gen4BattleParty.PARTY_CURSOR[self.index] - self:drawCursor(c) + if self.mode == "useItem" then + self:drawPrompt(self:text(Gen4BattleParty.TEXT.useOn, "Use on which POKéMON?")) + else + self:drawPrompt(self:text(Gen4BattleParty.TEXT.choose, "Choose a POKéMON.")) + end end function Gen4BattleParty:drawSelect() - local mon = self:party()[self.index] + local mon = self:mon() self:drawArt("bselect_bg", 0, 0) self:drawArt("bselect_fg", 0, 0) self:drawArt("bparty_shift", Gen4BattleParty.SHIFT_AT[1], Gen4BattleParty.SHIFT_AT[2]) @@ -366,7 +546,6 @@ function Gen4BattleParty:drawSelect() self:drawArt(egg and "bparty_small_3" or "bparty_small_0", Gen4BattleParty.CHECK_AT[1], Gen4BattleParty.CHECK_AT[2]) self:drawArt("bparty_cancel_0", Gen4BattleParty.CANCEL_AT[1], Gen4BattleParty.CANCEL_AT[2]) if mon then - -- the name, centred in its 96-wide window at (80, 32), the symbol after it local name = self:nameOf(mon) local g = not egg and self:genderOf(mon) local sym = (g == "male" or g == "female") @@ -383,18 +562,348 @@ function Gen4BattleParty:drawSelect() self:drawIcon(mon, ic[1], ic[2]) self:drawHeld(mon, ic[1], ic[2]) end - local function centred(text, wx, wy, ww) - local w = widthIn("subscreen", text) - self:drawWords(text, wx + math.floor((ww - w) / 2), wy + 6, "subscreen", 7, 8) - end - centred(self:text(Gen4BattleParty.TEXT.shift, "SHIFT"), 88, 96, 80) + self:buttonWords(self:text(Gen4BattleParty.TEXT.shift, "SHIFT"), 88, 96, 80) if not egg then - centred(self:text(Gen4BattleParty.TEXT.summary, "SUMMARY"), 8, 160, 88) - centred(self:text(Gen4BattleParty.TEXT.checkMoves, "CHECK MOVES"), 112, 160, 88) + self:buttonWords(self:text(Gen4BattleParty.TEXT.summary, "SUMMARY"), 8, 160, 88) + self:buttonWords(self:text(Gen4BattleParty.TEXT.checkMoves, "CHECK MOVES"), 112, 160, 88) + end +end + +-- PrintButtonText: FONT_SUBSCREEN TEXT_COLOR(7, 8, 9) centred at y + 6 +function Gen4BattleParty:buttonWords(text, wx, wy, ww) + local w = widthIn("subscreen", text) + self:drawWords(text, wx + math.floor((ww - w) / 2), wy + 6, "subscreen", 7, 8) +end + +-- ---------------------------------------------------------- detail pages -- + +-- PrintPokemonNameHeader in FONT_SYSTEM: the name at (40, 8) in palette 13's +-- (15, 14), the symbol right-aligned in the 72-wide window in (7, 8) / (3, 4); +-- then the icon, the status and the types (BattlePartySprites) +function Gen4BattleParty:drawHeader(mon) + if not mon then return end + self:label(self:nameOf(mon), 40, 8) + if not isEgg(mon) then + local g = self:genderOf(mon) + if g == "male" or g == "female" then + local sym = self:text(g == "male" and Gen4BattleParty.TEXT.male or Gen4BattleParty.TEXT.female, + g == "male" and "♂" or "♀") + self:drawWords(sym, 40 + 72 - widthIn("system", sym), 8, "system", + g == "male" and 7 or 3, g == "male" and 8 or 4, self.ink13) + end + end + local ic = Gen4BattleParty.PAGE_ICON + self:drawIcon(mon, ic[1], ic[2]) + local status = Gen4BattleParty.statusSeq(mon) + if status then + local at = Gen4BattleParty.PAGE_STATUS + self:drawArt("bparty_status_" .. status, at[1], at[2]) + end + local def = self.game.data.pokemon and self.game.data.pokemon[mon.species] + local types = def and def.types or {} + local t1, t2 = types[1], types[2] + if t2 == t1 then t2 = nil end + local at = Gen4BattleParty.PAGE_TYPES + if t1 then self:drawTypeArt("type_" .. tostring(t1):lower(), at[1][1], at[1][2]) end + if t2 then self:drawTypeArt("type_" .. tostring(t2):lower(), at[2][1], at[2][2]) end +end + +function Gen4BattleParty:drawPageButtons(middleKey, middleText) + local many = self:selectableCount() >= 2 + self:drawArt(many and "bparty_prev_0" or "bparty_prev_3", 0, 152) + self:drawArt(many and "bparty_next_0" or "bparty_next_3", 40, 152) + self:drawArt(middleKey, 96, 152) + self:buttonWords(middleText, 104, 160, 88) + self:drawArt("bparty_cancel_0", Gen4BattleParty.CANCEL_AT[1], Gen4BattleParty.CANCEL_AT[2]) +end + +function Gen4BattleParty:abilityRecord(mon) + local def = self.game.data.pokemon and self.game.data.pokemon[mon.species] + local ability = mon.ability + if not ability and def and def.abilities then + ability = def.abilities[mon.abilitySlot or 1] or def.abilities[1] + end + if not ability then return nil end + local list = self.game.data.abilities + local record = list and list[ability] + if record then return record end + local id = tonumber(ability) + if not id then + local okA, A = pcall(require, "src.import.Gen4Abilities") + for number, name in pairs(okA and A.NAMES or {}) do + if name == ability then id = number; break end + end end - self:drawCursor(Gen4BattleParty.SELECT_CURSOR[self.selectIndex]) + local text = self.game.data.text or {} + if id then + return { name = text[("TEXT_B0610_%05d"):format(id)] or tostring(ability), + description = text[("TEXT_B0612_%05d"):format(id)] } + end + return { name = tostring(ability) } +end + +local function lines(text) + local out = {} + for line in (tostring(text or ""):gsub("\r", "") .. "\n"):gmatch("(.-)\n") do out[#out + 1] = line end + while #out > 0 and out[#out] == "" do out[#out] = nil end + return out end +-- the EXP bar: eight 8-px tiles at map (10..17, 8), each tile 22 + its fill +function Gen4BattleParty:expBar(mon, x, y) + local img = self:image("bsummary_exp") + if not img then return end + local Growth = require("src.pokemon.Growth") + local def = self.game.data.pokemon and self.game.data.pokemon[mon.species] + local level = tonumber(mon.level) or 1 + local px = 0 + if level < 100 and def then + local base = Growth.expForLevel(def.growthRate, level) + local nxt = Growth.expForLevel(def.growthRate, level + 1) + local exp = tonumber(mon.exp) or base + if nxt > base then px = math.floor(math.max(0, exp - base) * 64 / (nxt - base)) end + end + local iw, ih = img:getDimensions() + for i = 0, 7 do + local fill = math.max(0, math.min(8, px - i * 8)) + local q = love.graphics.newQuad(fill * 8, 0, 8, 8, iw, ih) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, q, x + i * 8, y) + end +end + +function Gen4BattleParty:drawSummary() + local mon = self:mon() + local T = Gen4BattleParty.TEXT + self:drawArt("bsummary_bg", 0, 0) + self:drawArt("bsummary_fg", 0, 0) + self:drawPageButtons("bparty_small_0", self:text(T.checkMoves, "CHECK MOVES")) + if not mon then return end + self:drawHeader(mon) + local stats = mon.stats or {} + local level = tonumber(mon.level) or 1 + -- Lv. (1,4) / value (5,4); NEXT LV (1,6) / value right-aligned in (13,6) 6 wide + self:label(self:text(T.lv, "Lv."), 8, 32) + self:label(tostring(level), 40, 32) + self:label(self:text(T.nextLv, "NEXT LV"), 8, 48) + local Growth = require("src.pokemon.Growth") + local def = self.game.data.pokemon and self.game.data.pokemon[mon.species] + local toNext = 0 + if level < 100 and def then + toNext = math.max(0, Growth.expForLevel(def.growthRate, level + 1) - (tonumber(mon.exp) or 0)) + end + self:valueRight(tostring(toNext), 104, 48, 48) + self:expBar(mon, 80, 64) + -- HP (21,4) / "cur/max" around a centred slash in (24,4) 7 wide; the bar + -- in (25,6) 6 wide + self:label(self:text(T.hp, "HP"), 168, 32) + local slash = self:text(T.slash, "/") + local sw = widthIn("system", slash) + local sx = 192 + math.floor((56 - sw) / 2) + self:value(slash, sx, 32) + local cur = tostring(math.max(0, tonumber(mon.hp) or 0)) + self:value(cur, sx - widthIn("system", cur), 32) + self:value(tostring(maxHPOf(mon)), sx + sw, 32) + self:hpBar(mon, 200, 48) + local rows = { + { T.attack, "ATTACK", stats.attack or stats.atk, 56 }, + { T.defense, "DEFENSE", stats.defense or stats.def, 72 }, + { T.spAtk, "SP. ATK", stats.spAttack or stats.spatk or stats.specialAttack, 88 }, + { T.spDef, "SP. DEF", stats.spDefense or stats.spdef or stats.specialDefense, 104 }, + { T.speed, "SPEED", stats.speed or stats.spe, 120 }, + } + for _, r in ipairs(rows) do + self:label(self:text(r[1], r[2]), 168, r[4]) + self:valueRight(tostring(r[3] or "-"), 224, r[4], 24) + end + -- ability (1,9), its description (1,11) 18x4, held item (4,16) + local ab = self:abilityRecord(mon) + if ab then + self:label(tostring(ab.name or ""), 8, 72) + local y = 88 + for _, line in ipairs(lines(ab.description or ab.desc)) do + if y < 120 then self:value(line, 8, y) end + y = y + 16 + end + end + local item = mon.item and mon.item ~= 0 and self.game.data.items and self.game.data.items[mon.item] + self:label(item and item.name or self:text(T.noItem, "No item held"), 32, 128) +end + +function Gen4BattleParty:moveRecord(entry) + local id = type(entry) == "table" and entry.id or entry + local rec = id and self.game.data.moves and self.game.data.moves[id] + return rec, id +end + +function Gen4BattleParty:maxPP(entry, rec) + local base = rec and tonumber(rec.pp) or 0 + local ups = type(entry) == "table" and tonumber(entry.ppUps) or 0 + return base + math.min(3, math.max(0, ups)) * math.floor(base / 5) +end + +-- a move's window (14x5 at the button's tile + 1): the name centred at y 8 in +-- FONT_SUBSCREEN (7,8,9), "PP" at (40,24), "/" at 80, the max after it and +-- the current PP right-aligned against the slash, FONT_SYSTEM (15,14) on 9 +function Gen4BattleParty:moveWindow(entry, wx, wy, overridePP) + local rec = self:moveRecord(entry) + if not rec then return end + local name = rec.name or "?" + self:drawWords(name, wx + math.floor((112 - widthIn("subscreen", name)) / 2), wy + 8, "subscreen", 7, 8) + local T = Gen4BattleParty.TEXT + self:drawWords(self:text(T.movePP, "PP"), wx + 40, wy + 24, "system", 15, 14) + local slash = "/" + self:drawWords(slash, wx + 80, wy + 24, "system", 15, 14) + local max = self:maxPP(entry, rec) + self:drawWords(tostring(max), wx + 80 + widthIn("system", slash), wy + 24, "system", 15, 14) + local cur = tostring(overridePP or (type(entry) == "table" and entry.pp) or max) + self:drawWords(cur, wx + 80 - widthIn("system", cur), wy + 24, "system", 15, 14) +end + +function Gen4BattleParty:drawMoveButtons(mon, lift) + local moves = mon and mon.moves or {} + for i = 1, 4 do + local at = Gen4BattleParty.MOVE_BTN_AT[i] + local entry = moves[i] + local rec = self:moveRecord(entry) + self:drawArt(rec and "bparty_movebtn_0" or "bparty_movebtn_3", at[1], at[2]) + if rec then + self:moveWindow(entry, at[1] + 8, at[2]) + local ta = Gen4BattleParty.MOVE_TYPE_AT[i] + self:drawTypeArt("type_" .. tostring(rec.type or "normal"):lower(), ta[1], ta[2]) + end + end +end + +function Gen4BattleParty:drawMoves() + local mon = self:mon() + self:drawArt("bmoves_bg", 0, 0) + self:drawArt("bmoves_fg", 0, 0) + self:drawPageButtons("bparty_small_0", self:text(Gen4BattleParty.TEXT.summary, "SUMMARY")) + if not mon then return end + self:drawHeader(mon) + self:drawMoveButtons(mon) +end + +-- the move's numbers (RenderMoveSummaryScreen / RenderConfirmLearnMoveScreen) +function Gen4BattleParty:drawMoveDetail(entry, cleared) + local rec = self:moveRecord(entry) + local T = Gen4BattleParty.TEXT + self:label(self:text(T.pp, "PP"), 160, 40) + self:label(self:text(T.accuracy, "ACCURACY"), 8, 128) + self:label(self:text(T.power, "POWER"), 8, 104) + self:labelCentred(self:text(T.category, "CATEGORY"), 8, 64, 96) + if not rec then return end + self:label(rec.name or "?", 32, 40) + if cleared then return end + -- PP: "cur/max" round a centred slash in (23,5) 5 wide + local slash = "/" + local sw = widthIn("system", slash) + local sx = 184 + math.floor((40 - sw) / 2) + local max = self:maxPP(entry, rec) + local cur = tostring(type(entry) == "table" and entry.pp or max) + self:label(slash, sx, 40) + self:label(cur, sx - widthIn("system", cur), 40) + self:label(tostring(max), sx + sw, 40) + local acc = tonumber(rec.accuracy) or 0 + self:valueRight(acc == 0 and self:text(T.none, "---") or tostring(acc), 80, 128, 24) + local pow = tonumber(rec.power) or 0 + self:valueRight(pow <= 1 and self:text(T.none, "---") or tostring(pow), 80, 104, 24) + local class = tostring(rec.class or "physical"):lower() + local cid = class == "special" and T.special or class == "status" and T.status or T.physical + local cname = self:text(cid, class:upper()) + self:value(cname, 48 + math.floor((56 - widthIn("system", cname)) / 2), 80) + local y = 64 + for _, line in ipairs(lines(rec.description)) do + if y < 144 then self:value(line, 128, y) end + y = y + 16 + end + self:drawTypeArt("type_" .. tostring(rec.type or "normal"):lower(), 136, 48) + self:drawTypeArt("category_" .. class, 24, 88) +end + +function Gen4BattleParty:drawMoveSummary() + local mon = self:mon() + self:drawArt("bmovesum_bg", 0, 0) + self:drawArt("bmovesum_fg", 0, 0) + self:drawArt("bparty_cancel_0", Gen4BattleParty.CANCEL_AT[1], Gen4BattleParty.CANCEL_AT[2]) + local moves = mon and mon.moves or {} + for i = 1, 4 do + local at = Gen4BattleParty.MS_BTN_AT[i] + self:drawArt(self.moveSlot == i and "bparty_msbtn_2" or "bparty_msbtn_0", at[1], at[2]) + end + if not mon then return end + self:drawHeader(mon) + self:drawMoveDetail(moves[self.moveSlot]) +end + +function Gen4BattleParty:drawRestore() + local mon = self:mon() + self:drawArt("brestore_bg", 0, 0) + self:drawArt("brestore_fg", 0, 0) + self:drawArt("bparty_cancel_0", Gen4BattleParty.CANCEL_AT[1], Gen4BattleParty.CANCEL_AT[2]) + if not mon then return end + self:drawHeader(mon) + self:drawMoveButtons(mon) + self:drawPrompt(self:text(Gen4BattleParty.TEXT.restoreWhich, "Restore which move?")) +end + +function Gen4BattleParty:drawLearn() + local mon = self:mon() + self:drawArt("blearn_bg", 0, 0) + self:drawArt("blearn_fg", 0, 0) + self:drawArt("bparty_cancel_0", Gen4BattleParty.CANCEL_AT[1], Gen4BattleParty.CANCEL_AT[2]) + if not mon then return end + self:drawHeader(mon) + self:drawMoveButtons(mon) + local at = Gen4BattleParty.LEARN_BTN_AT + self:drawArt("bparty_learnbtn_0", at[1], at[2]) + local rec = self:moveRecord(self.moveId) + if rec then + local pp = tonumber(rec.pp) or 0 + self:moveWindow({ id = self.moveId, pp = pp }, at[1] + 8, at[2], pp) + local ta = Gen4BattleParty.MOVE_TYPE_AT[5] + self:drawTypeArt("type_" .. tostring(rec.type or "normal"):lower(), ta[1], ta[2]) + end +end + +function Gen4BattleParty:drawConfirm() + local mon = self:mon() + local T = Gen4BattleParty.TEXT + self:drawArt("bconfirm_bg", 0, 0) + self:drawArt("bconfirm_fg", 0, 0) + self:drawArt("bparty_confirm_0", 0, 152) + self:drawArt("bparty_cancel_0", Gen4BattleParty.CANCEL_AT[1], Gen4BattleParty.CANCEL_AT[2]) + local learning = self.moveSlot == 5 + local word = learning and self:text(T.cancel, "CANCEL") or self:text(T.forget, "FORGET") + self:drawWords(word, 56 + math.floor((96 - widthIn("subscreen", word)) / 2), 160 + 6, "subscreen", 7, 8) + if not mon then return end + self:drawHeader(mon) + local entry = learning and { id = self.moveId } or (mon.moves or {})[self.moveSlot] + self:drawMoveDetail(entry, self.hmRefused) + if self.hmRefused then + local y = 64 + for _, line in ipairs(lines((self:text(T.hm, "HM moves can't be\nforgotten now.")):gsub("[\v\f]", "\n"))) do + self:value(line, 128, y); y = y + 16 + end + end +end + +function Gen4BattleParty:drawBottom() + local s = self.screen + if s == "select" then self:drawSelect() + elseif s == "summary" then self:drawSummary() + elseif s == "moves" then self:drawMoves() + elseif s == "movesum" then self:drawMoveSummary() + elseif s == "restore" then self:drawRestore() + elseif s == "learn" then self:drawLearn() + elseif s == "confirm" then self:drawConfirm() + else self:drawParty() end + self:drawMessage() + if not self.message then self.cursor:draw(self.battle, self.game, self.t) end +end + +-- kept for callers that drew the cursor themselves function Gen4BattleParty:drawCursor(c) if not c then return end local Gen4Battle = require("src.battle.Gen4Battle") @@ -403,10 +912,6 @@ function Gen4BattleParty:drawCursor(c) { left = c[1] - i, top = c[2] - i, right = c[3] + i, bottom = c[4] + i }) end -function Gen4BattleParty:drawBottom() - if self.screen == "select" then self:drawSelect() else self:drawParty() end -end - function Gen4BattleParty:draw() local ok, SecondScreen = pcall(require, "src.ui.SecondScreen") if not ok then return end @@ -416,86 +921,238 @@ end -- ---------------------------------------------------------------- input -- +function Gen4BattleParty:pop() + if self.closed then return false end + self.closed = true + local stack = self.game.stack + if stack and stack.top and stack:top() ~= self then return true end + if stack then stack:pop() end + return true +end + function Gen4BattleParty:close() - self.game.stack:pop() + if not self:pop() then return end + if self.mode == "learnMove" then + if self.onChoose then self.onChoose(nil) end + return + end if self.onCancel then self.onCancel() end end function Gen4BattleParty:handOff(mon) - self.game.stack:pop() + if not self:pop() then return end if self.onSwitch then self.onSwitch(mon, self) end end -function Gen4BattleParty:openSummary(page) - local mon = self:party()[self.index] - if not mon then return end - require("src.ui.Screens").push(self.game, "SummaryMenu", { - mon = mon, readOnlyMoves = true, page = page, - }) +function Gen4BattleParty:selectableCount() + local n = 0 + for _, m in ipairs(self:party()) do if m and not isEgg(m) then n = n + 1 end end + return n +end + +-- UpdateSelectedPartyIndex: the next / previous member that is not an egg +function Gen4BattleParty:stepMon(delta) + local party = self:party() + local i = self.index + for _ = 1, 6 do + i = i + delta + if i < 1 then i = 6 elseif i > 6 then i = 1 end + if i == self.index then return false end + if party[i] and not isEgg(party[i]) then self.index = i; return true end + end + return false +end + +function Gen4BattleParty:setScreen(name, start) + self.screen = name + self.hmRefused = nil + local sc = SCREENS[name] + local mon = self:mon() + local moves = mon and mon.moves or {} + local enabled + if name == "party" then + enabled = function(i) + if i == 7 then return true end + return self:party()[i] ~= nil + end + start = start or self.index + elseif name == "select" then + start = start or self.selectIndex or 1 + elseif name == "moves" or name == "restore" then + enabled = function(i) return i > 4 or moves[i] ~= nil end + start = start or (name == "moves" and self.moveSlot) or 1 + elseif name == "movesum" then + enabled = function(i) return i > 4 or moves[i] ~= nil end + start = self.moveSlot + elseif name == "learn" then + enabled = function(i) return i ~= 6 end + start = start or self.learnSlot or 1 + elseif name == "confirm" then + enabled = function(i) return i ~= 2 end + start = 1 + end + self.cursor:set(Gen4BattleParty[sc[1]], start or 1, enabled) end -- A slot that cannot be selected (CheckCanPartySlotBeSelected: empty) is not -- a place the cursor or a tap goes. function Gen4BattleParty:pickSlot(slot) if slot == 7 then - if not self.forceSwitch then self:close() end + if not self.forceSwitch then Cursor.sound("decide", self.game.data); self:close() end return end - if not self:party()[slot] then return end + local mon = self:party()[slot] + if not mon then return end + Cursor.sound("decide", self.game.data) self.index = slot - self.screen = "select" + if self.mode == "useItem" then return self:useOn(mon) end self.selectIndex = 1 + self:setScreen("select", 1) +end + +-- PartyUseItemScreen: an ETHER-type item asks which move; everything else is +-- the item flow's (BagMenu useOn) answer, which closes the bag behind it +function Gen4BattleParty:useOn(mon) + if self.onMove and not isEgg(mon) then + self:setScreen("restore", 1) + return + end + self:handOff(mon) end function Gen4BattleParty:pickSelect(i) - local mon = self:party()[self.index] - if i == 1 then return self:handOff(mon) end - if i == 4 then self.screen = "party"; return end + local mon = self:mon() + if i == 1 then Cursor.sound("decide", self.game.data); return self:handOff(mon) end + if i == 4 then Cursor.sound("decide", self.game.data); self:setScreen("party"); return end if mon and isEgg(mon) then return end - if i == 2 then return self:openSummary(nil) end - if i == 3 then return self:openSummary("moves") end + Cursor.sound("decide", self.game.data) + if not Gen4BattleParty.pagesAvailable(self.game.data) then + -- an older cache: the port's own summary screen, as before + return require("src.ui.Screens").push(self.game, "SummaryMenu", { + mon = mon, readOnlyMoves = true, page = (i == 3) and "moves" or nil, + }) + end + self.lastSelect = i + if i == 2 then return self:setScreen("summary", 3) end + if i == 3 then self.moveSlot = 1; return self:setScreen("moves", 1) end +end + +function Gen4BattleParty:pickSummary(i) + if i == 1 or i == 2 then + if self:stepMon(i == 1 and -1 or 1) then Cursor.sound("decide", self.game.data) end + return + end + Cursor.sound("decide", self.game.data) + if i == 3 then self.moveSlot = 1; return self:setScreen("moves", 7) end + if i == 4 then return self:setScreen("select", 2) end +end + +function Gen4BattleParty:pickMoves(i) + local moves = (self:mon() or {}).moves or {} + if i <= 4 then + if not moves[i] then return end + Cursor.sound("decide", self.game.data) + self.moveSlot = i + return self:setScreen("movesum") + end + if i == 5 or i == 6 then + if self:stepMon(i == 5 and -1 or 1) then Cursor.sound("decide", self.game.data); self.moveSlot = 1 end + return + end + Cursor.sound("decide", self.game.data) + if i == 7 then return self:setScreen("summary", 3) end + if i == 8 then return self:setScreen("select", 3) end +end + +function Gen4BattleParty:pickMoveSummary(i) + local moves = (self:mon() or {}).moves or {} + if i <= 4 then + if i ~= self.moveSlot and not moves[i] then return end + Cursor.sound("decide", self.game.data) + self.moveSlot = i + self.cursor.index = i + return + end + Cursor.sound("decide", self.game.data) + return self:setScreen("moves", self.moveSlot) +end + +function Gen4BattleParty:pickRestore(i) + local mon = self:mon() + local moves = mon and mon.moves or {} + if i <= 4 then + if not moves[i] then return end + Cursor.sound("decide", self.game.data) + if not self:pop() then return end + if self.onMove then self.onMove(mon, i) end + return + end + Cursor.sound("decide", self.game.data) + return self:setScreen("party") +end + +function Gen4BattleParty:pickLearn(i) + if i == 6 then return end + Cursor.sound("decide", self.game.data) + if i == 7 then return self:close() end + self.learnSlot = i + self.moveSlot = i + return self:setScreen("confirm") end -local function step(grid, index, dir, prev) - local cell = grid[index] - local nxt = cell and cell[dir] - if nxt == "previous" then return prev or 2 end - return nxt or index +-- the move-learn helpers' own list of HMs, so a refusal here is the same +-- rule the rest of the port applies +function Gen4BattleParty:isHM(moveId) + local ok, M = pcall(require, "src.ui.MoveLearnMenu") + local hm = ok and M.hmMoves and M.hmMoves(self.game) or {} + return hm[moveId] and true or false +end + +function Gen4BattleParty:pickConfirm(i) + if i == 2 then return end + Cursor.sound("decide", self.game.data) + if i == 3 then return self:setScreen("learn", self.learnSlot) end + local slot = self.moveSlot + local mon = self:mon() + local entry = slot <= 4 and mon and (mon.moves or {})[slot] + if entry and self:isHM(entry.id) then + self.hmRefused = true + return + end + if not self:pop() then return end + if self.onChoose then self.onChoose(slot <= 4 and slot or nil) end +end + +function Gen4BattleParty:pick(i) + local s = self.screen + if s == "party" then return self:pickSlot(i) + elseif s == "select" then return self:pickSelect(i) + elseif s == "summary" then return self:pickSummary(i) + elseif s == "moves" then return self:pickMoves(i) + elseif s == "movesum" then return self:pickMoveSummary(i) + elseif s == "restore" then return self:pickRestore(i) + elseif s == "learn" then return self:pickLearn(i) + elseif s == "confirm" then return self:pickConfirm(i) end end function Gen4BattleParty:update(dt) self.t = (self.t or 0) + (dt or 1 / 60) local input = self.game.input if not input then return end - local dirs = { "up", "down", "left", "right" } - if self.screen == "party" then - for _, d in ipairs(dirs) do - if input:wasPressed(d) then - local n = self.index - for _ = 1, 7 do - n = step(Gen4BattleParty.PARTY_CURSOR, n, d) - if n == 7 or self:party()[n] then break end - end - self.index = n - return - end - end - if input:wasPressed("a") then return self:pickSlot(self.index) end - if input:wasPressed("b") then - if not self.forceSwitch then self:close() end - end - else - for _, d in ipairs(dirs) do - if input:wasPressed(d) then - local before = self.selectIndex - local n = step(Gen4BattleParty.SELECT_CURSOR, before, d, self.lastLower) - if before ~= 1 and n == 1 then self.lastLower = before end - self.selectIndex = n - return - end - end - if input:wasPressed("a") then return self:pickSelect(self.selectIndex) end - if input:wasPressed("b") then self.screen = "party" end + if self.message then + if input:wasPressed("a") or input:wasPressed("b") then self.message = nil end + return + end + -- on the list the cursor IS `index` (a caller may have moved it) + if self.screen == "party" and self:party()[self.index] ~= nil then self.cursor.index = self.index end + local r = self.cursor:tick(input) + if self.screen == "party" and self.cursor.index <= 6 then self.index = self.cursor.index end + if r == "back" then + if self.screen == "party" and self.forceSwitch then return end + return self:pick(SCREENS[self.screen].back) + elseif r then + if self.screen == "select" then self.selectIndex = r end + return self:pick(r) end end @@ -504,13 +1161,12 @@ function Gen4BattleParty:touchpressed(_, px, py) if not ok then return false end local x, y = SecondScreen.toLocal(self.game, px, py) if not x then return false end - local rects = self.screen == "party" and Gen4BattleParty.PARTY_TOUCH or Gen4BattleParty.SELECT_TOUCH - for i, r in ipairs(rects) do - -- { top, bottom, left, right }, bottom and right inclusive as stored - if y >= r[1] and y <= r[2] and x >= r[3] and x <= r[4] then - if self.screen == "party" then self:pickSlot(i) else self:pickSelect(i) end - return true - end + if self.message then self.message = nil; return true end + local rects = Gen4BattleParty[SCREENS[self.screen][2]] + local i = Cursor.hit(rects, x, y) + if i then + self.cursor:hide() + self:pick(i) end return true end diff --git a/src/ui/Gen4BoxMenu.lua b/src/ui/Gen4BoxMenu.lua index 594c01c93..db3f21eb8 100644 --- a/src/ui/Gen4BoxMenu.lua +++ b/src/ui/Gen4BoxMenu.lua @@ -45,7 +45,7 @@ PC.PARTY_EXIT={192,184} PC.WORDS={JUMP=24,WALLPAPER=25,NAME=26,['HEADER CANCEL']=27, ['SCENERY 1']=28,['SCENERY 2']=29,['SCENERY 3']=30,ETCETERA=31, MOVE=58,SUMMARY=61,WITHDRAW=62,STORE=63,ITEM=64,MARK=65,RELEASE=66,CANCEL=67, - CONFIRM=68,GIVE=70,TAKE=71,YES=78,NO=79} + CONFIRM=68,GIVE=70,TAKE=71,['SET ON LEFT']=76,['SET ON RIGHT']=77,YES=78,NO=79} PC.WALLPAPER_NAME=34 -- FOREST, the first of sixteen local FALLBACK_INK={species={{255,247,255},{115,115,132}},nickname={{239,230,255},{90,90,107}}, male={{148,230,239},{41,123,165}},female={{247,132,132},{197,41,41}},item={{255,247,255},{115,115,132}}, @@ -225,13 +225,77 @@ function PC:step() end local j=self.jump if j then - j.f=j.f+1 - if j.f==11 and not j.closing then self.message=self:say(8,'Jump to which Box?') end - if j.closing then j.c=j.c+1;if j.c>=7 then self.jump=nil end end + if j.closing then + j.c=j.c+1 + if j.c>=PC.JUMP_CLOSE_FRAMES then self.jump=nil;self.message='' end + else + j.f=j.f+1 + -- BOX_SELECTOR_DISPLAY_MESSAGE_START: the question once the popup is up + if j.f==PC.JUMP_OPEN_FRAMES then self.message=self:say(8,'Jump to which Box?') end + end end if self.dialStep then self:dialStep() end + self:subStep() end function PC:busy() return self.slide or self.paperFade end + +-- THE JUMP POPUP (ov19_021DB8E4, BoxSelectorPopup in box_app_manager.c). +-- ov19_021DC0A0 opens BG1 map 3's 32 x 10 band out from tile row 10, one row +-- up and one down a frame (four frames, then whole on the fifth); the seven +-- thumbnails (box selection - 3 .. + 3, at x 32 + 32 i, y 88) come in on the +-- next frame in OBJ rows 13-15 faded seven-sevenths to white and lose a +-- seventh a frame; at 0 the name window (10, 6) 12 x 2 -- the name centred on +-- x 48 in TEXT_COLOR(2, 8, 7) -- and the counts window (3, 13) 26 x 1 -- +-- two zero-padded digits per box, 32 apart -- go up. Left / right (and L / R) +-- move the selection and redraw at once (ov19_021DBB70); A jumps, B does not. +-- ov19_021DC1CC closes it: everything off, then the band folds back in. +PC.JUMP_BAND_FRAMES=5 +PC.JUMP_FADE=7 +PC.JUMP_OPEN_FRAMES=PC.JUMP_BAND_FRAMES+1+PC.JUMP_FADE +PC.JUMP_CLOSE_FRAMES=7 +function PC:openJump() + self.jump={f=0,box=self.game.save.currentBox or 1} + self.message='' +end +function PC:closeJump(target) + local j=self.jump + if not j or j.closing then return end + j.closing=true;j.c=0 + local cur=self.game.save.currentBox or 1 + if target and target~=cur then + local prev=self:previewMon() + self:bg3State() + self.game.save.currentBox=target + self:slideBox(cur,target,prev) + end +end +-- how many band rows are showing on each side of row 10 +function PC:jumpBandRows() + local j=self.jump + if not j then return 0 end + if j.closing then + if j.c<=1 then return 5 end + return math.max(0,5-(j.c-1)) + end + return math.min(5,j.f) +end +-- the thumbnails' fade to white, 0..1, or nil before they are up +function PC:jumpFade() + local j=self.jump + if not j or j.closing then return nil end + if j.f<=PC.JUMP_BAND_FRAMES then return nil end + local level=math.max(0,PC.JUMP_FADE-(j.f-PC.JUMP_BAND_FRAMES-1)) + return level/PC.JUMP_FADE +end +function PC:updateJump(input) + local j=self.jump + if j.closing or j.f faded) +function PC:markMask() + if self.opts.mode=='items' then return 1 end + return self.markFilter or 0 +end +function PC:iconFaded(mon) + local mask=self:markMask() + if not mon or mask==0 then return false end + if mask%2==1 then + local item=mon.heldItem or mon.item + if not item or item==0 then return true end + end + local marks=tonumber(mon.markings) or 0 + for i=0,5 do + if math.floor(mask/2^(i+1))%2==1 and math.floor(marks/2^i)%2==0 then return true end + end + return false +end +-- BoxApp_SetMonSpriteTransparencyMask +function PC:toggleMarkFilter(kind) + if kind==0 then self.markFilter=0 return end + local m,b=self.markFilter or 0,2^(kind-1) + self.markFilter=(math.floor(m/b)%2==1) and m-b or m+b +end +-- ov19_021DD9B8: the button of kind k lit? +function PC:markLit(kind) + local mask=self:markMask() + if kind==0 then return mask==0 end + return math.floor(mask/2^(kind-1))%2==1 +end +-- a corner button's cell for its sequence at tick t +local BUTTON_SEQS={[0]={{0,60}},{{1,8},{2,8}},{{3,4},{4,4}}} +local function seqCell(seq,t,loop) + local frames=BUTTON_SEQS[seq] or BUTTON_SEQS[0] + local total=0 + for _,f in ipairs(frames) do total=total+f[2] end + if loop then t=t%total end + local acc=0 + for _,f in ipairs(frames) do acc=acc+f[2];if t=16 + if t.kind=='open' then + -- case 0: the press plays out; 1: SUB_1 scrolls 8 frames; 2-3: SUB_2 + -- is put in place and fades in over 16 + if not t.p then if pressDone then t.p=1;t.n=0 end + elseif t.p==1 then + t.n=t.n+1 + s.sub1=math.floor(t.sub1From+(PC.SUB1_X[t.to]-t.sub1From)*t.n/t.frames) + if t.n>=t.frames then s.sub1=PC.SUB1_X[t.to];s.blend=0;s.cover=PC.SUB2_X[t.to];t.p=2;t.n=0 end + else + s.blend=PC.RAMP[math.min(15,t.n)];t.n=t.n+1 + if t.n>=16 then s.blend=16;s.buttons[t.to]={seq=0,t=0};s.task=nil;self:startItems() end + end + elseif t.kind=='switch' then + if not t.p then if pressDone then t.p=1;t.n=15 end + elseif t.p==1 then + t.n=t.n-1;s.blend=PC.RAMP[t.n] + if t.n<=0 then s.cover=PC.SUB2_X[t.to];t.p=2;t.n=0;t.sub1From=s.sub1 end + elseif t.p==2 then + t.n=t.n+1 + s.sub1=math.floor(t.sub1From+(PC.SUB1_X[t.to]-t.sub1From)*t.n/t.frames) + if t.n>=t.frames then s.sub1=PC.SUB1_X[t.to];s.blend=0;t.p=3;t.n=0 end + else + s.blend=PC.RAMP[math.min(15,t.n)];t.n=t.n+1 + if t.n>=16 then s.blend=16;s.buttons[t.to]={seq=0,t=0};s.task=nil;self:startItems() end + end + else -- close (ov19_021DD670) + if not t.p then t.p=1;t.n=15;t.sub1From=s.sub1 end + if t.p==1 then + t.n=t.n-1;s.blend=PC.RAMP[t.n] + if t.n<=0 then s.cover=0;t.p=2;t.n=0 end + elseif t.p==2 then + t.n=t.n+1 + s.sub1=math.floor(t.sub1From+(0-t.sub1From)*t.n/8) + if t.n>=8 then s.sub1=0;s.task=nil end + end + end + end + if s.items then + s.items.k=s.items.k+1 + if s.items.k>48 then s.items=nil;s.names=(s.dial==PC.DIAL_BOX) end + end + if s.scroll then + s.scroll.k=s.scroll.k+1 + if s.scroll.k>=s.scroll.frames then s.scroll=nil;s.names=(s.dial==PC.DIAL_BOX) end + end + -- TOUCH_BOX_JUMP_WAIT_FOR_INPUT_OR_JUMP: the jump, once the stylus is off + if s.dial==PC.DIAL_BOX and s.jumpWait and not s.scroll and not self.slide then + s.jumpWait=s.jumpWait+1 + if s.jumpWait>PC.JUMP_DELAY then + s.jumpWait=nil + local cur=self.game.save.currentBox or 1 + local target=s.offset+1 + if target~=cur then + local prev=self:previewMon() + self:bg3State() + self.game.save.currentBox=target + self:slideBox(cur,target,prev) + end + end + end + local c=self.compare + if c and c.setPress then c.setPress.t=(c.setPress.t or 0)+1 end +end + +-- WHERE AN ITEM IS: stop `p` (0..7, fractional between stops by index) +function PC.arcPoint(stop) + stop=math.max(0,math.min(7,stop)) + local a=math.floor(stop) + local b=math.min(7,a+1) + local idx=PC.STOPS[a]+(PC.STOPS[b]-PC.STOPS[a])*(stop-a) + idx=math.max(0,math.min(143,math.floor(idx))) + return PC.ARC[idx*2+1],PC.ARC[idx*2+2] +end +-- the stop of each item now: box b (0-based) sits at offset + 4 - b, the +-- markings button of kind t at 6 - ((t - scroll) mod 8) +function PC:itemStop(base) + local s=self.subState + local stop=base + if s.scroll then + local f=s.scroll.k/s.scroll.frames + stop=base-s.scroll.delta*(1-f) + end + if s.items then + -- ov19_021DDCC0: the item for stop p leaves stop 0 at 2 (6 - p) and takes + -- 6 p frames; ov19_021DDFFC: stop p leaves after 6 (6 - p), taking 6 p + local p=math.floor(base+0.5) + if p<1 or p>6 then return nil end + local start=(s.dial==PC.DIAL_BOX) and 2*(6-p) or 6*(6-p) + local f=math.max(0,math.min(1,(s.items.k-start)/math.max(1,6*p))) + stop=p*f + end + return stop +end +function PC:boxOnStop(b) + local s=self.subState + local count=Boxes.count() + local p=(s.offset+4-b)%count + if p>count/2 then p=p-count end + return p +end +function PC:markOnStop(kind) + local s=self.subState + return 6-((kind-s.markScroll)%8) +end + +-- the dial's turn (touch_dial.c) +local function angleOf(cx,cy,x,y) return math.deg(math.atan2(y-cy,x-cx))%360 end +function PC:dialCentre() + local s=self.subState + return s.dial==PC.DIAL_MARKS and 255 or 0,192 +end +function PC.dialSteps(initial,current,direction) + local change=current-initial + local sign=change<0 and -1 or 1 + change=math.abs(change) + local i=0 + if change>=4.219 then i=1 end + if change>=33.75 then i=2 end + return i*sign*direction +end +function PC:inRing(x,y) + local cx,cy=self:dialCentre() + local d=(x-cx)^2+(y-cy)^2 + return d=PC.RING.inner^2 +end +function PC:dialScroll(delta) + local s=self.subState + if s.dial==PC.DIAL_BOX then + s.offset=(s.offset+delta)%Boxes.count() + else + s.markScroll=(s.markScroll+delta)%8 + end + s.names=false + s.scroll={k=0,delta=delta,frames=6*math.abs(delta)} +end +function PC:dialEnd() + local s=self.subState + s.drag=nil + if s.dial==PC.DIAL_BOX then + s.jumpWait=((self.game.save.currentBox or 1)-1~=s.offset) and 0 or nil + end +end +function PC:dialMove(x,y) + local s=self.subState + local d=s.drag + if not d then return end + if not (x and self:inRing(x,y)) then return self:dialEnd() end + if s.scroll then return end + local cx,cy=self:dialCentre() + local amount=PC.dialSteps(d.angle,angleOf(cx,cy,x,y),cx<128 and 1 or -1) + if amount~=d.amount then + local delta=amount-d.amount + d.amount=amount + self:dialScroll(delta) + end +end + +-- compare mode's two slots (CompareModeHelper) +function PC:compareState() + if not self.compare then self.compare={slot=0,mode=0,mons={},has={}} end + return self.compare +end +-- what a slot shows: the cursor's Pokemon in the active slot, the one it was +-- last set to in the other +function PC:compareMon(slot) + local c=self:compareState() + if slot==c.slot then return (self:previewMon()) end + return c.has[slot] and c.mons[slot] or nil +end +-- BOX_MENU_SET_ON_LEFT / RIGHT and the corner buttons: the active slot keeps +-- the cursor's Pokemon and the other slot becomes the active one +function PC:toggleCompareSlot() + local c=self:compareState() + local mon=self:previewMon() + if not mon then return false end + c.mons[c.slot]=mon;c.has[c.slot]=true + c.setPress={slot=c.slot,t=0} + c.slot=1-c.slot + return true +end +function PC:nextCompareMode() + local c=self:compareState() + c.mode=(c.mode+1)%3 +end + +function PC:bottomTouch(x,y) + if self:compareMode() then + local c=self:compareState() + for i,b in ipairs(PC.BUTTON) do + if (x-b[1])^2+(y-b[2])^2=24 then p=p-8 end + local c=(ink.jumpPaper or {})[p] + if c then g.setColor(c[1]/255,c[2]/255,c[3]/255,1);g.rectangle('fill',x0+9,y0+10,14,12) end + local box=Boxes.ensure(self.game.save)[n+1] or {} + for slot=1,30 do + local mon=box[slot] + if mon then + local colour + if mon.egg or mon.isEgg then colour=(mon.species==490) and 1 or 8 + else + local def=self.game.data.pokemon and self.game.data.pokemon[mon.species] + colour=tonumber(def and def.bodyColor) or 0 + end + local bc=(ink.jumpBody or {})[colour] + if bc then + g.setColor(bc[1]/255,bc[2]/255,bc[3]/255,1) + g.rectangle('fill',x0+10+((slot-1)%6)*2,y0+11+math.floor((slot-1)/6)*2,2,2) + end + end + end + g.setColor(1,1,1,1) +end +function PC:drawBottom() + local g=love.graphics + local ink=self:ink() + if self:compareMode() then return self:drawCompareBottom(ink) end + local s=self:sub() + local bd=ink.subBackdrop or {82,148,214} + g.setColor(bd[1]/255,bd[2]/255,bd[3]/255,1);g.rectangle('fill',0,0,256,192);g.setColor(1,1,1,1) + local bg=self:art('sub_bg') + if bg then g.draw(bg,0,0) end + -- OBJ priority 3: the corner buttons, under every BG but SUB_3 + for i=1,2 do + local b=s.buttons[i] + local cell=seqCell(b.seq,b.t,false) + self:subSprite('sub_button_'..cell,i==1 and 0 or 256,184,i==2) + end + if s.blend>0 and s.cover~=0 then drawWrapped(self,self:art('sub_cover'),s.cover,s.blend/16) end + -- OBJ priority 2: the dial's items + if s.dial==PC.DIAL_BOX and not s.task then + for b=0,Boxes.count()-1 do + local p=self:boxOnStop(b) + if p>=-2 and p<=9 then + local stop=self:itemStop(p) + if stop and (stop>0 and stop<7 or (s.items and stop>=0 and stop<=7)) then + local x,y=PC.arcPoint(stop) + self:drawSubBox(b,x,y,ink) + end + end + end + elseif s.dial==PC.DIAL_MARKS and not s.task then + for kind=0,7 do + local stop=self:itemStop(self:markOnStop(kind)) + if stop and (stop>0 and stop<7 or (s.items and stop>=0 and stop<=7)) then + local x,y=PC.arcPoint(stop) + self:subSprite('sub_mark_'..(kind*2+(self:markLit(kind) and 1 or 0)),256-x,y) + end + end + end + drawWrapped(self,self:art('sub_dials'),s.sub1,1) + -- SUB_0: the six box names along the arc + if s.dial==PC.DIAL_BOX and s.names then + local pair=ink.subName or {{255,255,255},{90,90,107}} + local count=Boxes.count() + Font.pushStyle({text=rgb(pair[1]),shadow=rgb(pair[2])}) + g.setColor(1,1,1,14/16) + for i,r in ipairs(PC.NAME_RECTS) do + local n=(s.offset+3-(i-1))%count + Font.draw(Font.fit(self:boxNameOf(n+1),100),r[1]+4,r[2]+2) + end + g.setColor(1,1,1,1) + Font.popStyle() + end +end + +-- ov19_021DEC04's three pages +function PC:compareValues(mon) + local stats=mon.stats or {} + local data=self.game.data + local nature=mon.nature + if type(nature)~='string' and mon.personality then + nature=((data.constants or {}).natureOrder or {})[mon.personality%25+1] + end + return nature,{mon.level or 1,tonumber(mon.maxHp) or tonumber(stats.hp) or 0, + stats.attack or stats.atk or 0,stats.defense or stats.def or 0, + stats.spAttack or stats.spatk or 0,stats.spDefense or stats.spdef or 0,stats.speed or stats.spe or 0} +end +function PC:drawCompareBottom(ink) + local g=love.graphics + local c=self:compareState() + local bd=ink.cmpBackdrop or {173,222,247} + g.setColor(bd[1]/255,bd[2]/255,bd[3]/255,1);g.rectangle('fill',0,0,256,192);g.setColor(1,1,1,1) + local page=({[0]='cmp_battle','cmp_contest','cmp_moves'})[c.mode] + local bg=self:art(page) + if bg then g.draw(bg,0,0) end + local pairs2={[0]=ink.cmpLeft or {{255,255,255},{90,90,107}},ink.cmpRight or {{255,255,255},{90,90,107}}} + local function centred(text,tx,tw,y,slot) + if not text or text=='' then return end + text=Font.fit(tostring(text),tw*8) + say(text,tx*8+math.floor((tw*8-Font.width(text))/2),y,pairs2[slot]) + end + local function word(n,fallback) return self:word(PC.MESSAGE_BANK,n,fallback) end + -- the column labels (13, 4) 6 x 16 + if c.mode==0 then + local keys={'nature','level','hp','attack','defense','spAttack','spDefense','speed'} + local fall={'NATURE','Lv.','HP','ATTACK','DEFENSE','SP. ATK','SP. DEF','SPEED'} + for i,k in ipairs(keys) do centred(word(PC.COMPARE_TEXT[k],fall[i]),13,6,32+16*(i-1),0) end + elseif c.mode==2 then + centred(word(PC.COMPARE_TEXT.move,'MOVE'),13,6,32,0) + end + for slot=0,1 do + local mon=self:compareMon(slot) + local nx=slot==0 and 3 or 20 + if mon then + local egg=mon.egg or mon.isEgg + centred(egg and (mon.nickname or 'EGG') or self:nameOf(mon),nx,9,8,slot) + local q=word(PC.COMPARE_TEXT.unknown,'???') + if c.mode==0 then + if egg then for i=0,7 do centred(q,nx,9,32+16*i,slot) end + else + local nature,values=self:compareValues(mon) + centred(nature,nx,9,32,slot) + for i,v in ipairs(values) do centred(tostring(math.floor(v)),nx,9,32+16*i,slot) end + end + elseif c.mode==2 then + local mx=slot==0 and 1 or 20 + for i=1,4 do + if egg then centred(q,mx,11,32+4+24*(i-1),slot) + else + local m=(mon.moves or {})[i] + local id=(type(m)=='table' and (m.id or m.move)) or m + local rec=id and id~=0 and (self.game.data.moves or {})[id] + if id and id~=0 then centred(rec and rec.name or tostring(id),mx,11,32+4+24*(i-1),slot) end + end + end + end + end + end + -- the contest pentagon's dots: (centre + 10% of the way) + stat / 256 of the rest + if c.mode==1 then + local keys={'cool','beauty','cute','smart','tough'} + for _,slot in ipairs({1-c.slot,c.slot}) do + local mon=self:compareMon(slot) + if mon and not (mon.egg or mon.isEgg) then + local con=mon.contest or {} + for i,k in ipairs(keys) do + local t=PC.COMPARE_DOTS[i] + local bx=PC.COMPARE_CENTRE[1]+(t[1]-PC.COMPARE_CENTRE[1])*0.1 + local by=PC.COMPARE_CENTRE[2]+(t[2]-PC.COMPARE_CENTRE[2])*0.1 + local v=math.max(0,math.min(255,tonumber(con[k]) or 0)) + self:subSprite('cmp_dot_'..slot,bx+(t[1]-bx)*v/256,by+(t[2]-by)*v/256) + end + end + end + end + -- the slot buttons: the active one up, the other set (cell 2, after the + -- press 1 x 8 / 2 x 16 when it was just set) + for slot=0,1 do + local cell=0 + if slot~=c.slot and c.has[slot] then + cell=2 + if c.setPress and c.setPress.slot==slot and (c.setPress.t or 0)<8 then cell=1 end + end + self:subSprite((slot==0 and 'cmp_left_' or 'cmp_right_')..cell,slot==0 and 0 or 256,184,slot==1) + end + -- the mode toggle: 5 / 3 alternating, 5 / 4 while held (ov19_021DF930) + local t=math.floor((self.t or 0)*60) + local held=c.pressed + local cell=(t%8<4) and 5 or (held and 4 or 3) + self:subSprite('cmp_toggle_'..cell,128,176) + -- the two Pokemon icons at (112, 10) and (144, 10) + for slot=0,1 do + local mon=self:compareMon(slot) + if mon then self:drawIcon(mon,slot==0 and 112 or 144,10) end + end +end + -- -------------------------------------------------------------------- draw -- -- the standard window frame (pl_winframe standard_system, which this screen @@ -588,23 +1273,147 @@ function PC:drawMenu(ink) end end +-- BG3 as the ring keeps it: each visible column is one of a slot's 23 +-- (paper_NN, one 8-pixel column of it) or the 0x18 fill in slot 0's palette, +-- at the ring's scroll plus the slide's; a wallpaper fading out washes its +-- slot to white. False with no `paper_NN` in the cache (an older one). +function PC:drawBG3() + local g=love.graphics + local b=self:bg3State() + local first=b.slots[0] or b.slots[1] + if not (first and self:art(('paper_%02d'):format(first.paper))) then return false end + local bgShift=self:slideShift() + local scroll=(b.scroll+(self.slide and bgShift or 0))%512 + self.paperQuads=self.paperQuads or {} + for sx=0,256,8 do + local ringX=(sx+scroll)%512 + local c=math.floor(ringX/8) + local x=sx-(ringX%8) + local col=b.cols[c] + local img,qx,slot + if col then + slot=b.slots[col.slot] + img=slot and self:art(('paper_%02d'):format(slot.paper));qx=col.x*8 + end + if not img then + slot=b.slots[0] or first + img=self:art(('paper_fill_%02d'):format(slot.paper));qx=0 + col={slot=0} + end + if img then + local w,h=img:getDimensions() + local key=tostring(w)..':'..qx + if not self.paperQuads[key] then self.paperQuads[key]=g.newQuad(qx,0,8,192,w,h) end + g.setColor(1,1,1,1) + g.draw(img,self.paperQuads[key],x,0) + local white=self:paperWhite(col.slot) + if white>0 then g.setColor(1,1,1,white);g.rectangle('fill',x,0,8,192);g.setColor(1,1,1,1) end + end + end + return true +end + +-- the popup over BG1 and its OBJ thumbnails (see PC.JUMP_*) +function PC:drawJump(ink) + local g=love.graphics + local j=self.jump + local band=self:art('jump_band') + local rows=self:jumpBandRows() + if band and rows>0 then + local w,h=band:getDimensions() + g.setColor(1,1,1,1) + -- the band's top `rows` rows above tile row 10, its bottom `rows` below + g.draw(band,g.newQuad(0,0,256,rows*8,w,h),0,80-rows*8) + g.draw(band,g.newQuad(0,80-rows*8,256,rows*8,w,h),0,80) + end + local fade=self:jumpFade() + if not fade then return end + local count=Boxes.count() + local boxes=Boxes.ensure(self.game.save) + local paperInk=ink.jumpPaper or {} + local bodyInk=ink.jumpBody or {} + for i=0,6 do + local n=(j.box-1+i-3)%count+1 + local cx,cy=32+i*32,88 + if self:sprite('jump_thumb',cx,cy) then + local x0,y0=cx-16,cy-16 + -- ov19_021DBBA8: the inside in the wallpaper's colour, a 2 x 2 dot per + -- Pokemon in its body colour + local p=self:wallpaperOf(n);if p>=24 then p=p-8 end + local c=paperInk[p] + if c then g.setColor(c[1]/255,c[2]/255,c[3]/255,1);g.rectangle('fill',x0+9,y0+10,14,12) end + local box=boxes[n] or {} + for slot=1,30 do + local mon=box[slot] + if mon then + local colour + if mon.egg or mon.isEgg then colour=(mon.species==490) and 1 or 8 + else + local def=self.game.data.pokemon and self.game.data.pokemon[mon.species] + colour=tonumber(def and def.bodyColor) or 0 + end + local bc=bodyInk[colour] + if bc then + g.setColor(bc[1]/255,bc[2]/255,bc[3]/255,1) + g.rectangle('fill',x0+10+((slot-1)%6)*2,y0+11+math.floor((slot-1)/6)*2,2,2) + end + end + end + if fade>0 then g.setColor(1,1,1,fade);g.rectangle('fill',x0,y0,32,32) end + g.setColor(1,1,1,1) + end + end + if fade>0 then return end + -- the name window (10, 6) 12 x 2 on colour 7, and the counts (3, 13) 26 x 1 on 4 + local plate=ink.jumpPlate or {247,132,132} + g.setColor(plate[1]/255,plate[2]/255,plate[3]/255,1);g.rectangle('fill',80,48,96,16) + local name=self:boxNameOf(j.box) + say(name,80+48-math.floor(Font.width(name)/2),48,ink.jumpName or {{255,255,255},{214,90,90}}) + local paper=ink.jumpCount or {230,165,90} + g.setColor(paper[1]/255,paper[2]/255,paper[3]/255,1);g.rectangle('fill',24,104,208,8) + g.setColor(1,1,1,1) + for i=0,6 do + local n=(j.box-1+i-3)%count+1 + local box=boxes[n] or {} + local k=0 + for slot=1,30 do if box[slot] then k=k+1 end end + self:number('special_jump',k,2,true,24+32*i,104) + end +end + function PC:draw() local g=love.graphics local ink=self:ink() local bd=ink.backdrop or FALLBACK_INK.backdrop g.setColor(bd[1]/255,bd[2]/255,bd[3]/255,1);g.rectangle('fill',0,0,256,192);g.setColor(1,1,1,1) local paperId=self:wallpaperId() - local paper=self:art(('wallpaper_%02d'):format(paperId)) - local fresh=paper~=nil - if paper then g.draw(paper,0,0) + local fresh=self:drawBG3() + if not fresh then + local paper=self:img(('storage/wallpaper_%02d'):format(paperId));if paper then g.draw(paper,88,0) end + end + -- the box name, in the wallpaper's own colours 2 (letter) and 1 (shadow), + -- and the thirty icons -- during a slide the old box's and the new one's, + -- the new one 184 along in the direction of travel + local bgShift,iconShift=self:slideShift() + local s=self.slide + local function boxLayer(n,dx,iconDx) + local p=self:wallpaperOf(n) + local pair=(ink.wallpaper or {})[p] or {{99,99,99},{255,255,255}} + local name=Font.fit(self:boxNameOf(n),144) + say(name,172-Font.width(name)/2+dx,13,pair) + local box=Boxes.ensure(self.game.save)[n] or {} + for slot=1,30 do + local m=box[slot] + self:drawIcon(m,112+(slot-1)%6*24+iconDx,40+math.floor((slot-1)/6)*24,self:iconFaded(m) and PC.XLU or 1) + end + end + local cur=self.game.save.currentBox or 1 + if s then + boxLayer(s.oldBox,-bgShift,iconShift) + boxLayer(cur,184*s.dir-bgShift,184*s.dir+iconShift) else - paper=self:img(('storage/wallpaper_%02d'):format(paperId));if paper then g.draw(paper,88,0) end + boxLayer(cur,0,0) end - -- the box name, in the wallpaper's own colours 2 (letter) and 1 (shadow) - local pair=(ink.wallpaper or {})[paperId] or {{99,99,99},{255,255,255}} - local name=Font.fit(self:boxName(),144) - say(name,172-Font.width(name)/2,13,pair) - for slot=1,30 do self:drawIcon(self:box()[slot],112+(slot-1)%6*24,40+math.floor((slot-1)/6)*24) end local hx,hy,where=self:handPoint() -- the buttons sit under BG2 (BG priority 2) local pressed=0 @@ -617,7 +1426,24 @@ function PC:draw() else g.setColor(0.8,0.9,1,1);g.rectangle('fill',112,0,120,192);g.setColor(1,1,1,1) end for i=1,6 do local p=PC.PARTY_SLOTS[i] - self:drawIcon(self.game.save.party[i],p[1],p[2]) + local m=self.game.save.party[i] + self:drawIcon(m,p[1],p[2],self:iconFaded(m) and PC.XLU or 1) + end + end + -- MOVE ITEMS (ov19_021DE3E8): the previewed Pokemon's item at half size + -- (sequence 6) on its slot + (10, 8) + if self.opts.mode=='items' and not self.held and not self.menu then + local m=self:previewMon() + local item=m and (m.heldItem or m.item) + if item and item~=0 then + local x,y + if self.partyOpen then + local p=PC.PARTY_SLOTS[self.partyIndex] + if p then x,y=p[1]+10,p[2]+8 end + elseif self.row>=1 and self.row<=5 then + x,y=112+(self.col-1)*24+10,40+(self.row-1)*24+8 + end + if x then self:drawItemIcon(item,x,y,0.5) end end end local mon @@ -635,6 +1461,7 @@ function PC:draw() local hand=self.held and 2 or 0 if not self:sprite(('cursor_%02d'):format(hand),hx,hy) then self:drawCursor(hand,hx,hy) end if self.menu then self:drawMenu(ink) end + if self.jump then self:drawJump(ink) end if self.message and self.message~='' then Font.drawDialogueBox(1,20,29,4) local y=168 @@ -643,6 +1470,41 @@ function PC:draw() if y>184 then break end end end + self:drawSubscreen() +end +-- the bottom screen through SecondScreen, when one is shown +function PC:drawSubscreen() + local shown,SS=self:bottomShown() + if not shown then return end + SS.draw(self.game,function() self:drawBottom() end) +end +-- Gen3BoxMenu's icon, with the XLU of a filtered-out Pokemon +local ICON_PERIOD=0.32 +function PC:drawIcon(mon,cx,cy,alpha) + local img,frameH=self:iconFor(mon) + if not img then return end + local iw,ih=img:getDimensions() + frameH=math.min(frameH or ih,ih) + local frames=math.max(1,math.floor(ih/frameH)) + local frame=frames>1 and (math.floor(((self.t or 0)%(ICON_PERIOD*frames))/ICON_PERIOD)%frames) or 0 + self.quads=self.quads or {} + local key=('%d:%d:%d'):format(iw,frameH,frame) + if not self.quads[key] then self.quads[key]=love.graphics.newQuad(0,frame*frameH,iw,frameH,iw,ih) end + love.graphics.setColor(1,1,1,alpha or 1) + love.graphics.draw(img,self.quads[key],math.floor(cx-iw/2),math.floor(cy-frameH/2)) + love.graphics.setColor(1,1,1,1) +end +-- an item icon (gen4_graphics items/icon_NNN) centred at (x, y), scaled +function PC:drawItemIcon(item,x,y,scale) + local def=self.game.data.items and self.game.data.items[item] + local id=tonumber(def and def.id) or tonumber(item) + local img=id and self:img(('items/icon_%03d'):format(id)) + if not img then return false end + scale=scale or 1 + local w,h=img:getDimensions() + love.graphics.setColor(1,1,1,1) + love.graphics.draw(img,math.floor(x-w*scale/2),math.floor(y-h*scale/2),0,scale,scale) + return true end -- an older cache's hand (gen4_graphics storage/cursor_NN) function PC:drawCursor(sequence,x,y) @@ -653,7 +1515,18 @@ function PC:drawCursor(sequence,x,y) love.graphics.draw(img,x+(rec.originX or -img:getWidth()/2),y+(rec.originY or 0)) end end -function PC:touchpressed(_,px,py) +function PC:touchpressed(id,px,py) + -- the bottom screen first, when it is shown (SecondScreen.toLocal); a tap + -- from the panel that misses it is still not a tap on the boxes + local bx,by=self:bottomPoint(px,py) + if bx then + self.subPointer={id=id} + return self:bottomTouch(bx,by) + end + if self.game.secondScreenInjecting then return true end + if self.jump or self:busy() then return true end + local s=self.subState + if s and (s.task or s.dial~=PC.DIAL_NONE) then return true end local r=require('src.render.Renderer').uiPresentation if not r or px=r.x+r.w or py>=r.y+r.h then return false end local x,y=(px-r.x)/r.scaleX,(py-r.y)/r.scaleY @@ -674,4 +1547,18 @@ function PC:touchpressed(_,px,py) elseif x>=100 and x<244 and y>=28 and y<148 then self.col=math.floor((x-100)/24)+1;self.row=math.floor((y-28)/24)+1;self:choose() end return true end +function PC:touchmoved(id,px,py) + if not (self.subPointer and self.subPointer.id==id) then return false end + local s=self.subState + if s and s.drag then self:dialMove(self:bottomPoint(px,py)) end + return true +end +function PC:touchreleased(id) + if not (self.subPointer and self.subPointer.id==id) then return false end + self.subPointer=nil + local s=self.subState + if s and s.drag then self:dialEnd() end + if self.compare then self.compare.pressed=nil end + return true +end return PC diff --git a/src/ui/Gen4ContestActing.lua b/src/ui/Gen4ContestActing.lua index 3ab567c25..608b7f36d 100644 --- a/src/ui/Gen4ContestActing.lua +++ b/src/ui/Gen4ContestActing.lua @@ -15,6 +15,13 @@ -- Controls: the d-pad moves over the 2x2 move buttons, A confirms; LEFT/RIGHT -- pick a judge, DOWN reaches EXIT, A confirms, B (or EXIT) goes back to the -- moves; A advances the messages. +-- +-- The sounds are overlay017's (ov17_02243C28.c's acting commands, +-- ov17_0223F7E4.c's buttons): the opening fanfare and applause, the last +-- turn's call, the cursor and the choices, your entrance, the hearts as they +-- climb (each a semitone higher), the Voltage's applause or drop, and the +-- judges' shares. Not heard: the max-Voltage stars' own flight sounds +-- (ov17_02243750 / ov17_022434E0), the Lua drawing no such animation. local Font = require("src.render.Font") local T = require("src.import.Gen4Text") @@ -23,6 +30,36 @@ local A = require("src.pokemon.Gen4ContestActing") local Act = {} Act.__index = Act +-- a cartridge sound effect (src/core/Sound.playSe), nothing when headless +local function se(self, name, opts) + local data = self.game and self.game.data or self.data + local ok, Sound = pcall(require, "src.core.Sound") + if ok and type(Sound) == "table" and Sound.playSe and data then return Sound.playSe(data, name, opts) end +end + +-- a sound `delay` frames from now +function Act:later(delay, name, opts) + self.timers = self.timers or {} + self.timers[#self.timers + 1] = { at = (self.clock or 0) + delay, name = name, opts = opts } +end + +-- a step in the message queue that makes a sound rather than a page +function Act:cue(fn) self.queue[#self.queue + 1] = { cue = fn } end + +-- ov17_02241F34: the hearts climbing from `from` to `to` points, one every +-- three frames (ov17_02244D98), each SEQ_SE_DP_CON_003 pitched up a +-- semitone (64 / 64ths) per heart already shown +function Act:heartsClimb(from, to) + local n = 0 + local p = from + while p < to do + p = p + 10 + local hearts = math.min(24, math.floor(p / 10)) + self:later(3 * n, "SEQ_SE_DP_CON_003", { pitch = 2 ^ ((hearts - 1) / 12) }) + n = n + 1 + end +end + -- Contest_Text_TimeForActingCompetition, by contest type local INTRO = { [0] = 40, 39, 35, 37, 38 } local BEGIN_PRACTICE, NEXT_LOWEST, JUDGING_OVER = 71, 68, 69 @@ -40,7 +77,13 @@ function Act.new(game, contest, opts) self.onDone = opts.onDone local practice = require("src.pokemon.Gen4Contest").isPractice(contest.competition) self:say(204, practice and BEGIN_PRACTICE or INTRO[contest.type]) - self.after = function() self:startTurn() end + -- ov17_02243E2C: the opening line's fanfare; the applause 30 frames after + -- it has been read (ov17_022431A0) + se(self, "SEQ_SE_DP_CON_032") + self.after = function() + self:later(30, "SEQ_SE_DP_CON_007") + self:startTurn() + end return self end @@ -99,24 +142,62 @@ end function Act:sayEvents(list) local c = self.c + self.run = self.run or { [0] = 0, 0, 0, 0 } for _, ev in ipairs(list) do if ev.kind == "performed" then self:say(204, PERFORMED, { [0] = self:name(ev.id), self:judgeName(ev.judge), self:moveName(ev.move) }) if ev.already then self:say(204, ALREADY) end + elseif ev.kind == "appeal" or ev.kind == "bonus" then + -- ov17_02244D98: the performer's hearts for the turn climb + local from = self.run[ev.id] + local to = from + ev.points + self.run[ev.id] = to + self:cue(function() self:heartsClimb(from, to) end) elseif ev.kind == "effect" then self:sayEffect(ev) elseif ev.kind == "voltage" then local j = self:judgeName(ev.judge) + -- ov17_0224519C: the crowd's applause as a Voltage rises + -- (ov17_022431A0), a SEQ_SE_DP_CON_001 for each star lost + if ev.delta > 0 then + self:cue(function() se(self, "SEQ_SE_DP_CON_007") end) + else + self:cue(function() + for k = 0, math.floor(-ev.delta / 10) - 1 do self:later(3 * k, "SEQ_SE_DP_CON_001") end + end) + end if ev.delta > 0 then self:say(204, VOLT_UP + ev.mtype, { [0] = j }) if math.floor(ev.level / 10) == 4 then self:say(204, PRETTY_EXCITED) - elseif ev.level >= A.MAX_VOLTAGE then self:say(204, GOING_WILD, { [0] = tostring(math.floor(ev.bonus / 10)) }) end + elseif ev.level >= A.MAX_VOLTAGE then + self:say(204, GOING_WILD, { [0] = tostring(math.floor(ev.bonus / 10)) }) + -- ov17_0224519C (states 21-22): the bonus's hearts climb + local from = self.run[ev.id] + local to = from + (ev.bonus or 0) + self.run[ev.id] = to + self:cue(function() self:heartsClimb(from, to) end) + end else self:say(204, VOLT_DOWN + ev.mtype, { [0] = j }) end elseif ev.kind == "judge" then local slots = { [0] = self:judgeName(ev.judge) } for k, id in ipairs(ev.ids) do slots[k] = self:name(id) end + -- ov17_022456E8: each one's share of the judge, by how many others + -- chose the same -- none, one, two, three -- and its hearts climbing + local others = ev.count - 1 + local share = ({ [0] = 30, 20, 10, 0 })[others] + self:cue(function() + if others == 0 then se(self, "SEQ_SE_DP_CON_023") + elseif others == 1 then se(self, "SEQ_SE_DP_CON_022") + elseif others == 2 then se(self, "SEQ_SE_DP_CON_021") + else se(self, "SEQ_SE_DP_CON_001") end + end) + for _, id in ipairs(ev.ids) do + local from = self.run[id] + self.run[id] = from + share + self:cue(function() self:heartsClimb(from, from + share) end) + end self:say(204, IMPRESSED[ev.count], slots) elseif ev.kind == "lowestFirst" then self:say(204, NEXT_LOWEST) @@ -147,6 +228,8 @@ function Act:startTurn() T.buffer(self.game, tostring(self.s.turn + 1)) self.prompt = T.resolve(self.data, 204, last and LAST_MOVE or WHICH_MOVE, self.game) end + -- ov17_022449B8: the last performance's call + if last then se(self, "SEQ_SE_DP_CON_029") end end function Act:runTurn() @@ -162,6 +245,7 @@ function Act:runTurn() A.beginTurn(s, choices) self.mode = "show" self.pos = 0 + self.run = { [0] = 0, 0, 0, 0 } self:nextPerformance() end @@ -169,6 +253,8 @@ function Act:nextPerformance() local s = self.s if self.pos < 4 then self.performer = s.order[self.pos + 1] + -- ov17_02243F68: your Pokemon's entrance, 11 frames in + if self.performer == 0 then self:later(11, "SEQ_SE_DP_CON_031") end local ev = A.perform(s, self.pos) self.pos = self.pos + 1 self:sayEvents(ev) @@ -191,37 +277,78 @@ function Act:finish() if self.onDone then self.onDone() end end +-- the sounds due this frame +function Act:tickSounds() + self.clock = (self.clock or 0) + 1 + local timers = self.timers + if not timers then return end + for i = #timers, 1, -1 do + local t = timers[i] + if self.clock >= t.at then + table.remove(timers, i) + se(self, t.name, t.opts) + end + end +end + +-- the queue's sound steps at its front, run as they come up +function Act:runCues() + while type(self.queue[1]) == "table" do + local item = table.remove(self.queue, 1) + item.cue() + end +end + function Act:update() local input = self.game.input + self:tickSounds() + self:runCues() if #self.queue > 0 then - if input:wasPressed("a") or input:wasPressed("b") then table.remove(self.queue, 1) end + if input:wasPressed("a") or input:wasPressed("b") then + table.remove(self.queue, 1) + self:runCues() + end if #self.queue == 0 and self.after then local f = self.after self.after = nil f() + self:runCues() end return end - if self.after then local f = self.after; self.after = nil; return f() end + if self.after then + local f = self.after + self.after = nil + f() + return self:runCues() + end if self.mode == "move" then -- the four buttons are a 2x2 grid (Unk_ov17_022532D0) local moves = self:playerMoves() local c = self.cursor - 1 if input:wasPressed("up") or input:wasPressed("down") then c = (c + 2) % 4 end if input:wasPressed("left") or input:wasPressed("right") then c = c - c % 2 + (1 - c % 2) end + -- ov17_02241004: the cursor's click when it moves + if c + 1 ~= self.cursor then se(self, "SEQ_SE_DP_SELECT") end self.cursor = c + 1 if input:wasPressed("a") and A.canUse(self.s, 0, moves[self.cursor]) then + -- ov17_022449B8: a move chosen + se(self, "SEQ_SE_DP_DECIDE") self.chosenMove = moves[self.cursor] self.mode = "judge" end elseif self.mode == "judge" then -- three judges over EXIT (Unk_ov17_022532A8); EXIT goes back to the moves + local was = self.judge if self.judge < 3 then self.lastJudge = self.judge end if input:wasPressed("left") and self.judge < 3 then self.judge = (self.judge + 2) % 3 end if input:wasPressed("right") and self.judge < 3 then self.judge = (self.judge + 1) % 3 end if input:wasPressed("down") then self.judge = 3 end if input:wasPressed("up") and self.judge == 3 then self.judge = self.lastJudge or 0 end + if self.judge ~= was then se(self, "SEQ_SE_DP_SELECT") end local a, b = input:wasPressed("a"), input:wasPressed("b") + -- ov17_022449B8: a judge (or EXIT, which B also picks) chosen + if a or b then se(self, "SEQ_SE_DP_DECIDE") end if b or (a and self.judge == 3) then self.judge = self.lastJudge or 0 self.mode = "move" @@ -378,6 +505,7 @@ end function Act:message() local msg = self.queue[1] + if type(msg) ~= "string" then msg = nil end if not msg and self.mode == "move" then msg = self.prompt elseif not msg and self.mode == "judge" then msg = T.resolve(self.data, 204, CHOOSE_JUDGE) end return msg diff --git a/src/ui/Gen4ContestDance.lua b/src/ui/Gen4ContestDance.lua index 09655a7cc..d28ef190b 100644 --- a/src/ui/Gen4ContestDance.lua +++ b/src/ui/Gen4ContestDance.lua @@ -23,8 +23,14 @@ -- THE BUTTONS (ov17_022492DC): UP or X is JUMP, DOWN or B is FRONT, LEFT or Y -- is LEFT, RIGHT or A is RIGHT. -- --- Not drawn: the 3D particles and fireworks. The --- sound effects wait on the contest's SE table. +-- THE SOUNDS are the cartridge's: the applause as the stage opens +-- (ov17_0223DAD0's init) and again once the song has ended (ov17_0224E86C); +-- the spotlight's click as the lead steps up and again as the back row takes +-- the second half, and as the lights come up (ov17_0224CDC0 / ov17_0224D41C); +-- your-turn chime (ov17_0224C9F0); the dancers moving to their new places +-- (ov17_0224DFF8); and every move's step and verdict (ov17_0224B20C). +-- +-- Not drawn: the 3D particles and fireworks. local Font = require("src.render.Font") local T = require("src.import.Gen4Text") @@ -35,6 +41,13 @@ local Dance = require("src.pokemon.Gen4ContestDance") local UI = {} UI.__index = UI +-- a cartridge sound effect (src/core/Sound.playSe), nothing when headless +local function se(self, name, opts) + local data = self.game and self.game.data or self.data + local ok, Sound = pcall(require, "src.core.Sound") + if ok and type(Sound) == "table" and Sound.playSe and data then return Sound.playSe(data, name, opts) end +end + UI.POS = { { 128, 96, 1.0 }, { 48, 40, 0.75 }, { 128, 40, 0.75 }, { 208, 40, 0.75 } } UI.BUBBLE = { { 128, 88 }, { 48, 32 }, { 128, 32 }, { 208, 32 } } UI.NOTE_Y = { 176, 168, 176, 184 } @@ -51,6 +64,8 @@ function UI.new(game, c, opts) local nick = self:name(0) T.buffer(game, nick) self:say(T.resolve(game.data, Dance.BANK, practice and 20 or 8, game)) + -- ov17_0223DAD0's init: the audience's applause as the stage opens + se(self, "SEQ_SE_DP_CON_007") return self end @@ -108,6 +123,8 @@ end function UI:startDance() self.mode, self.frame, self.message = "dance", 0, nil + -- ov17_0224D41C: the stage lights come up before the song + se(self, "SEQ_SE_DP_CON_019") local ok, Music = pcall(require, "src.core.Music") if ok and Music.play then pcall(Music.play, self.data, self.d.song.seq) end end @@ -129,6 +146,41 @@ function UI:moved(move, isLead, slot, ms, row) if isLead then self.tint = { colour = UI.TINT[move.dir], from = f } end if move.quality == Dance.MISS and not isLead then self.tint = { colour = { 0, 0, 0 }, from = f } end self.notes[#self.notes + 1] = { grid = move.grid, dir = move.dir, row = row, own = move.id == 0, ms = ms } + self:moveSounds(move, isLead, last) +end + +-- ov17_0224B20C's sounds for a move: the measure's tallies (unk_A1D the +-- run of EXCELLENTs, unk_A21 the misses, unk_A25..27 the back row's +-- verdicts), the fireworks' applause for a clean measure (ov17_0224CB80), +-- the step (the dancer's last a bigger one), then the verdict -- heard for +-- every dancer when you lead, otherwise only for the lead's and your own +function UI:moveSounds(move, isLead, last) + local d, tally, id, q = self.d, self.tally, move.id, move.quality + if not tally then return end + if q == Dance.EXCELLENT then tally.run[id] = tally.run[id] + 1 else tally.run[id] = 0 end + if q == Dance.MISS then tally.miss[id] = tally.miss[id] + 1 end + if not isLead then tally.back[q] = tally.back[q] + 1 end + if last then + if isLead then + if tally.run[id] == d.moves or tally.miss[id] == 0 then se(self, "SEQ_SE_DP_CON_007") end + elseif tally.back[0] + tally.back[1] + tally.back[2] == d.moves * 3 + and (tally.back[0] == d.moves * 3 or tally.back[2] == 0) then + se(self, "SEQ_SE_DP_CON_007") + end + end + if last then se(self, "SEQ_SE_DP_CON_028") else se(self, "SEQ_SE_DP_CON_027_3") end + if not (self.ms.lead == 0 or isLead or id == 0) then return end + if q == Dance.EXCELLENT then + if tally.run[id] == d.moves then se(self, "SEQ_SE_DP_CON_023") + elseif tally.run[id] == d.moves - 1 then se(self, "SEQ_SE_DP_CON_022") + else se(self, "SEQ_SE_DP_CON_021") end + elseif q == Dance.GOOD then + se(self, "SEQ_SE_DP_CON_024") + elseif move.wrong then + se(self, "SEQ_SE_DP_CON_026") + else + se(self, "SEQ_SE_DP_CARD10") + end end function UI:beginMeasure(r, m) @@ -136,6 +188,12 @@ function UI:beginMeasure(r, m) local ms = Dance.newMeasure(d, r, m) self.ms, self.notes, self.events = ms, {}, {} self.cool = { [0] = -999, -999, -999, -999 } + -- ov17_0224DF54: the tallies start again each measure + self.tally = { run = { [0] = 0, 0, 0, 0 }, miss = { [0] = 0, 0, 0, 0 }, back = { [0] = 0, 0, 0 } } + -- ov17_0224D710: the spotlight on the lead (ov17_0224CDC0), and the + -- your-turn chime when that is you (ov17_0224C9F0) + se(self, "SEQ_SE_DP_CON_019") + if ms.lead == 0 then se(self, "SEQ_SE_DP_CON_031") end if ms.lead ~= 0 then for _, e in ipairs(Dance.npcLead(d, ms.lead)) do self.events[#self.events + 1] = { t = e.t, dir = e.dir, id = ms.lead, lead = true } @@ -187,6 +245,8 @@ function UI:tick() end self.endFrames = self.endFrames + 1 if self.endFrames == 60 then + -- ov17_0224E86C: a second after the song, the applause + se(self, "SEQ_SE_DP_CON_007") if Contest.isPractice(self.c.competition) then return self:finish() end local id = Dance.leading(self.d) local e = self.c.contestants[id] @@ -208,12 +268,24 @@ function UI:tick() self.round = r if m and (not self.ms or self.ms.round ~= r or self.ms.measure ~= m) then self:beginMeasure(r, m) end if not m then + -- ov17_0224DFF8: after each round but the last, the dancers move to + -- their new places (ov17_0223ED1C) + if f >= Dance.roundStart(d, 0) and r < 3 and not (self.rotated or {})[r] then + self.rotated = self.rotated or {} + self.rotated[r] = true + se(self, "SEQ_SE_DP_CON_030") + end self.instructionText = self.instructionText or self:instruction(math.min(3, f < Dance.roundStart(d, 0) and 0 or r + 1)) end local ms = self.ms if not (ms and m) then return end local t = f - ms.start - if t >= d.half and not self.copiesPlanned then self:planCopies() end + if t >= d.half and not self.copiesPlanned then + self:planCopies() + -- the spotlight swings to the back row, and your chime when you follow + se(self, "SEQ_SE_DP_CON_019") + if ms.lead ~= 0 then se(self, "SEQ_SE_DP_CON_031") end + end -- the computer dancers, on their frames for i = #self.events, 1, -1 do local e = self.events[i] diff --git a/src/ui/Gen4ContestScreen.lua b/src/ui/Gen4ContestScreen.lua index d52597524..6b3e8e690 100644 --- a/src/ui/Gen4ContestScreen.lua +++ b/src/ui/Gen4ContestScreen.lua @@ -20,6 +20,13 @@ -- bank 218's announcements; the audience (19/20) on the bottom screen when -- there is one. Messages go in pl_winframe's window, where the overlay puts -- them (tile 2, 19, 27 x 4). +-- +-- THE SOUNDS are the cartridge's: the Visual round's door and applause +-- (ov17_0223CB1C), each entry's curtain, spotlight and rating pops +-- (ov17_02247A48 / ov17_022476F8); the results board's applause, each +-- round's bars rising (ov17_02252858) and the places stamped +-- (ov17_02252580), then the winner's applause (ov17_0225266C). The +-- fanfare and results songs are BGM, not effects, and are not played here. local Font = require("src.render.Font") local C = require("src.pokemon.Gen4Contest") @@ -30,6 +37,77 @@ local Screen = {} Screen.__index = Screen Screen.isOpaque = true +-- a cartridge sound effect (src/core/Sound.playSe), nothing when headless +local function se(self, name, opts) + local data = self.game and self.game.data or self.data + local ok, Sound = pcall(require, "src.core.Sound") + if ok and type(Sound) == "table" and Sound.playSe and data then return Sound.playSe(data, name, opts) end +end + +local function stopSe(self, name) + local data = self.game and self.game.data or self.data + local ok, Sound = pcall(require, "src.core.Sound") + if ok and type(Sound) == "table" and Sound.stopSe and data then return Sound.stopSe(data, name) end +end + +-- a sound (or a stop, when `stop`) `delay` frames from now +function Screen:later(delay, name, stop) + self.timers[#self.timers + 1] = { at = self.clock + delay, name = name, stop = stop } +end + +function Screen:tickSounds() + self.clock = self.clock + 1 + for i = #self.timers, 1, -1 do + local t = self.timers[i] + if self.clock >= t.at then + table.remove(self.timers, i) + if t.stop then stopSe(self, t.name) else se(self, t.name) end + end + end +end + +-- the sounds a message page brings with it, the first frame it is up +function Screen:shown(item) + local page, c = self.pages[self.page], self.c + if page == "visual" then + if item.stage then + -- ov17_02249014: the curtain (30 frames, or at once on A); + -- ov17_02247C5C: the spotlight; ov17_02247F8C: applause, then after + -- 15 frames a pop (ov17_022477B8) every 4 for each star and heart, + -- and applause again once they are all up + self.sawEntry = true + se(self, "SEQ_SE_DP_CON_020") + self:later(4, "SEQ_SE_DP_CON_019") + self:later(8, "SEQ_SE_DP_CON_007") + local n = C.stars(c, item.rate) + C.hearts(c, item.rate) + for k = 0, n - 1 do self:later(8 + 15 + 4 * k, "SEQ_SE_DP_CON_003") end + self:later(n > 0 and 8 + 4 * n + 12 or 8, "SEQ_SE_DP_CON_007") + elseif self.sawEntry then + -- ov17_0223D99C: the round's closing applause + self.sawEntry = false + se(self, "SEQ_SE_DP_CON_007") + end + elseif page == "final" then + if item.final then + -- ov17_02252580: the four places stamped, fourth to first, 32 frames + -- apart (ov17_0225131C); ov17_0225266C: the winner's applause + for k = 0, 3 do self:later(1 + 32 * k, "SEQ_SE_DP_DECIDE") end + self:later(1 + 32 * 4, "SEQ_SE_DP_CON_007") + elseif item.reveal then + -- ov17_02252858: the drum roll, 45 frames, then the bars' rising + -- sound while they grow a pixel a frame, and the cymbal at the top + se(self, "SEQ_SE_DP_CON_033") + local longest = 0 + for id = 0, 3 do longest = math.max(longest, c.bars[id][item.reveal] or 0) end + if longest > 0 then + self:later(46, "SEQ_SE_DP_CON_034") + self:later(46 + longest + 1, "SEQ_SE_DP_CON_032") + self:later(46 + longest + 1, "SEQ_SE_DP_CON_034", true) + end + end + end +end + Screen.VISUAL_TEXT = 209 -- contest_visual_competition Screen.RESULTS_TEXT = 218 -- contest_results @@ -43,7 +121,8 @@ local function name(e, data) end function Screen.new(game, contest, onDone) - local self = setmetatable({ game = game, c = contest, onDone = onDone, page = 1, queue = {} }, Screen) + local self = setmetatable({ game = game, c = contest, onDone = onDone, page = 1, queue = {}, + timers = {}, clock = 0 }, Screen) local t = contest.competition local visual = t == C.OFFICIAL or t == C.ACTING_ONLY_UNK0 or t == C.DANCE_ONLY_UNK1 or t == C.VISUAL or t == C.PRACTICE_VISUAL @@ -92,11 +171,16 @@ function Screen:enter() local page = self.pages[self.page] local c, data = self.c, self.game.data self.queue, self.stage, self.revealed, self.final = {}, nil, 0, false + self.timers, self.shownItem = {}, nil if page == "dressup" then self.dressup = require("src.ui.Gen4ContestDressUp").new(self.game, c, { onDone = function() self.dressupDone = true end, }) elseif page == "visual" then + -- ov17_0223CB1C: the door as the stage opens, the applause once it has + -- faded in + se(self, "SEQ_SE_DP_DOOR_OPEN") + self:later(1, "SEQ_SE_DP_CON_007") C.scoreVisual(c, data) -- ov17_0223CB1C: the MC's welcome, then each entry in entry order -- (entry n is contestant 3 - n), then the next round's call @@ -117,6 +201,8 @@ function Screen:enter() onDone = function() self.actingDone = true end, }) elseif page == "final" then + -- ContestFinalScoring_Init: the applause as the board comes up + se(self, "SEQ_SE_DP_CON_007") C.place(c) -- ov17_02250744: the announcement, each round's bars in turn, the winner self:say(Screen.RESULTS_TEXT, 0) @@ -133,6 +219,12 @@ function Screen:enter() end function Screen:advance() + if self.pages[self.page] == "visual" and self.sawEntry then + -- ov17_0223D99C's applause, with no next-round call to carry it + self.sawEntry = false + se(self, "SEQ_SE_DP_CON_007") + end + if self.pages[self.page] == "final" then stopSe(self, "SEQ_SE_DP_CON_034") end self.page = self.page + 1 if self.page > #self.pages then self.game.stack:pop() @@ -165,6 +257,11 @@ function Screen:update(dt) end return self.acting:update(dt) end + self:tickSounds() + if self.queue[1] and self.queue[1] ~= self.shownItem then + self.shownItem = self.queue[1] + self:shown(self.shownItem) + end if input:wasPressed("a") or input:wasPressed("b") then if #self.queue > 1 then table.remove(self.queue, 1) else self:advance() end end diff --git a/src/ui/Gen4EvolutionState.lua b/src/ui/Gen4EvolutionState.lua index 6c76f5fc4..6c0767364 100644 --- a/src/ui/Gen4EvolutionState.lua +++ b/src/ui/Gen4EvolutionState.lua @@ -1,49 +1,429 @@ --- Platinum art with the shared evolution transaction and move-learning flow. +-- PLATINUM'S EVOLUTION SCENE -- src/evolution.c, run on its own clock. +-- +-- The base (Gen3EvolutionState) keeps the shared transaction: the species +-- change, the congratulation fanfare and the move learning after it. What is +-- Platinum's here is everything on screen and when: +-- +-- * BG3 is egg_data.narc 0/1/8 (Gen4EvolutionArt `background`); the mon is +-- its front sprite centred at (128, 80); the bottom screen is the +-- Poketch's "unavailable" picture (poketch.narc 10/11/12); +-- * window 0 is the only thing BG3 and the 3D layer (sprites, particles) +-- show through -- 0..160 at first, closing two lines a frame to 40..120 +-- while the forms change and opening again after -- with the backdrop +-- (BG palette 0 colour 0) and the message box outside it; +-- * the timeline: fade in from black (PaletteData_StartFade wait 1, sprites +-- 16->0 at once); "What? X is evolving!" (battle_strings 915) and the cry; +-- 20 frames; particle emitter 0 and both forms fading to white (16 steps, +-- a step every 5 frames); 40 frames of the window closing, then emitters +-- 1, 2, 7, 8, 9 and 11 and the forms trading places -- the old one's +-- scale falling 8/256 a frame as the new one's rises, then back, the step +-- doubling each round up to 64 -- for as long as any emitter lives; then +-- emitters 3, 4, 5, 6 and 10, the new form alone, the BG fading to white +-- (wait 2), the window opening; 8 frames after the fade emitter 12, the BG +-- fading back (wait 4) and the sprite (a step every 4 frames); the new +-- cry, and "Congratulations! Your X evolved into Y!" (918). +-- * B while the forms trade places (when the evolution may be stopped): +-- a fade to white and back, the old form, its cry, "Huh? X stopped +-- evolving!" (919). +-- * the particles are shinka_demo_particle.narc member 0 (Gen4EvolutionArt), +-- simulated by Gen4ParticleSystem, each emitter at (0, 8 * 172) fx32 -- +-- 10.75 pixels above the screen centre at the port's 32 px a unit. +-- +-- SOUND: Evolution_Main's four effects, each on the frame its state fires -- +-- SEQ_SE_DP_W025 with emitter 0 (EVOLUTION_STATE_START_FADE), SEQ_SE_DP_W060C +-- with emitters 1/2/7/8/9/11 (CLAMP_IN), SEQ_SE_DP_W062 with 3/4/5/6/10 +-- (ANIMATION_ALTERNATE_POKEMON), SEQ_SE_DP_W080 with 12 (CLAMP_OUT_AND_FADE). +-- The text callback's SEQ_SE_DP_KON (move forgetting's "Poof!") belongs to +-- the base's move learning, not here. +-- +-- NOT HERE: the cry/anim waits, which are fixed frame counts rather than the +-- cry's real length. + local Base = require('src.ui.Gen3EvolutionState') local Assets = require('src.render.Assets') local State = {}; State.__index = State setmetatable(State, { __index = Base }); State.isOpaque = true + +local W, H = 256, 192 +local MON_X, MON_Y = 128, 80 +local PARTICLE_Y = -(8 * 172) / 4096 * 32 + +local function se(self, name, opts) + local data = self.game and self.game.data or self.data + local ok, Sound = pcall(require, "src.core.Sound") + if ok and type(Sound) == "table" and Sound.playSe and data then return Sound.playSe(data, name, opts) end +end + +local TEXT = { + evolving = { 915, "What?\n{STRVAR_1 1 0 0} is evolving!" }, + congratulations = { 918, "Congratulations! Your {STRVAR_1 1 0 0}\nevolved into {STRVAR_1 1 1 0}!" }, + stopped = { 919, "Huh? {STRVAR_1 1 0 0}\nstopped evolving!" }, +} + +function State.text(game, role, one, two) + local t = TEXT[role] + local s = (((game.data or {}).text or {})[("TEXT_B0368_%05d"):format(t[1])]) or t[2] + local function safe(v) return (tostring(v or ""):gsub("%%", "%%%%")) end + s = s:gsub("{STRVAR_1 1,? ?0,? ?0}", safe(one)):gsub("{STRVAR_1 1,? ?1,? ?0}", safe(two)) + s = s:gsub("{WAIT %d+}", ""):gsub("{[^}]*}", ""):gsub("[\r\011]", "") + return s +end + +local function image(path) + if not path then return nil end + local ok, img = pcall(Assets.image, path) + if ok and img then img:setFilter('nearest', 'nearest'); return img end +end + function State.new(game, mon, species, onDone, via, evo) local self = Base.new(game, mon, species, onDone, via, evo) setmetatable(self, State) local function front(id) - local path=require('src.pokemon.Sprites').path(game.data,id,'front',{mon=mon,kind='evolution'}) - if path then local ok,img=pcall(Assets.image,path);if ok then return img end end - end - self.oldSprite,self.newSprite=front(mon.species),front(species) - local rec = (((game.data.gen4_graphics or {}).screens) or {})['evolution/background'] - local path = type(rec) == 'table' and rec.path or rec - if path then local ok, image = pcall(Assets.image, path); if ok then self.background = image end end - require('src.core.Sound').playCry(game.data, mon.species) + return image(require('src.pokemon.Sprites').path(game.data, id, 'front', { mon = mon, kind = 'evolution' })) + end + self.oldSprite, self.newSprite = front(mon.species), front(species) + local art = (game.data or {}).gen4_evolution_art or {} + local rec = art.background or (((game.data.gen4_graphics or {}).screens) or {})['evolution/background'] + self.background = image(type(rec) == 'table' and rec.path or rec) + local ink = (game.data or {}).gen4_evolution_ink or {} + self.emitters = ink.emitters + self.backdrop = ink.backdrop or { 0, 0, 0 } + self.textureBits = ink.textures or {} + self.particleArt = {} + for i = 0, 15 do + local r = art[('particle_%d'):format(i)] + if r then self.particleArt[i + 1] = r end + end + local blank = (((game.data.gen4_graphics or {}).screens) or {})['poketch/unavailable'] + self.bottom = image(type(blank) == 'table' and blank.path or blank) + self.systems = {} + self.scale = { 256, 0 } + self.white = { 0, 0 } -- sprite fade levels, 0..16, toward white + self.spriteBlack = 16 -- the opening fade from black + self.bgFade = { level = 16, target = 0, wait = 1, colour = 'black', count = 0 } + self.win = { top = 0, bottom = 160 } + self.f = 0 return self end -function State:uiSize() return 256, 192 end + +function State:uiSize() return W, H end function State:wantsFillScale() return true end -function State:sgbPalettes() return {require('src.render.PaletteFX').trueColorZone(0,0,31,23)} end +function State:sgbPalettes() return { require('src.render.PaletteFX').trueColorZone(0, 0, 31, 23) } end + +-- ------------------------------------------------------------ the clocks -- + +-- PaletteData_StartFade: two sixteenths a step, a step every wait + 1 frames. +function State:fade(target, wait, colour) + self.bgFade = { level = self.bgFade.level, target = target, wait = wait, colour = colour or self.bgFade.colour, count = wait } +end + +function State:fadeBusy() return self.bgFade.level ~= self.bgFade.target end + +function State:tickFade() + local f = self.bgFade + if f.level == f.target then return end + if f.count < f.wait then f.count = f.count + 1; return end + f.count = 0 + if f.level < f.target then f.level = math.min(f.target, f.level + 2) else f.level = math.max(f.target, f.level - 2) end +end + +-- PokemonSprite_StartFade: one sixteenth a step, a step every delay + 1. +function State:spriteFade(from, to, delay) + self.sprite = { level = from, target = to, delay = delay, count = 0 } +end + +function State:tickSpriteFade() + local s = self.sprite + if not s or s.level == s.target then return end + if s.count > 0 then s.count = s.count - 1; return end + s.count = s.delay + s.level = s.level + (s.level < s.target and 1 or -1) +end + +function State:emit(...) + local P = require('src.battle.Gen4ParticleSystem') + for _, id in ipairs({ ... }) do + local sys = self.emitters and P.resource(self.emitters, id) + if sys then + sys:start(97 + id) + sys.emitterX, sys.emitterY = 0, PARTICLE_Y + self.systems[#self.systems + 1] = sys + end + end +end + +function State:particlesActive() + return #self.systems > 0 +end + +-- ----------------------------------------------------------------- flow -- + +-- The scene's own lines stay in the message window while it runs (they are +-- printed to BG1 and never cleared), so the opening line is a TextBox this +-- screen owns and steps itself; the closing lines wait for a button on top. +function State:say(role, onDone, owned) + local TextBox = require('src.render.TextBox') + local text = State.text(self.game, role, self.oldName, self.newName) + if owned then + self.box = TextBox.new(self.game, text, nil, { stay = {} }) + self.boxDone, self.boxWait = onDone, 0 + return + end + self.box = nil + self.game.stack:push(TextBox.new(self.game, text, onDone)) +end + +function State:begin() + if self.began then return end + self.began = true + self.phase = 'opening' + self.t = 0 + self:spriteFade(16, 0, 0) + self.spriteColour = 'black' + require('src.core.Sound').playCry(self.game.data, self.mon.species) + self:say('evolving', function() self.phase, self.t = 'delay', 0 + local Music = require('src.core.Music') + Music.play(self.game.data, Music.special(self.game.data, 'evolution')) + end, true) +end + +function State:stop() + self.phase, self.t = 'cancel', 0 + self.canceled = true + self:fade(16, 0, 'white') + self.spriteColour = 'white' +end + +function State:finish() + local game = self.game + local Evolution = require('src.pokemon.Evolution') + local Music = require('src.core.Music') + Evolution.apply(game, self.mon, self.newSpecies, self.via, self.evo) + local fanfare = Music.special(game.data, 'evolved') + if fanfare then Music.playOnce(game.data, fanfare) end + self:say('congratulations', function() + Music.restoreMap(game.data) + game.stack:pop() + Evolution.learnEvolutionMoves(game, self.mon, self.onDone) + end) +end + +-- Evolution_Main's shrinkAndGrowState: even, the old form shrinks as the new +-- grows; odd, the reverse; a full round doubles the step (to 64). +function State:shrinkGrow() + local d = self.delta + if self.growState % 2 == 0 then + self.scale[1] = math.max(0, self.scale[1] - d); self.scale[2] = self.scale[2] + d + if self.scale[1] == 0 then self.growState = self.growState + 1 end + else + self.scale[1] = self.scale[1] + d; self.scale[2] = math.max(0, self.scale[2] - d) + if self.scale[2] == 0 then + self.growState = self.growState + 1 + if self.delta < 64 then self.delta = self.delta * 2 end + end + end +end + +function State:update() + if not self.began then return self:begin() end + self.f = self.f + 1 + if self.box then + pcall(self.box.update, self.box, 1 / 60) + -- printed, the cry and the opening animation over (fixed: one second) + if self.box.done and self.boxDone then + self.boxWait = self.boxWait + 1 + if self.boxWait >= 60 then local fn = self.boxDone; self.boxDone = nil; fn() end + end + end + self.t = self.t + 1 + self:tickFade() + self:tickSpriteFade() + if self.growState then self:shrinkGrow() end + local live = {} + for _, sys in ipairs(self.systems) do if sys:update() then live[#live + 1] = sys end end + self.systems = live + + local p = self.phase + if p == 'delay' then + if self.t >= 20 then + self:emit(0) + self:spriteFade(0, 16, 4); self.spriteColour = 'white' + se(self, "SEQ_SE_DP_W025") -- evolution.c:361 + self.phase, self.t = 'clampIn', 0 + end + elseif p == 'clampIn' then + if self.win.top < 40 then self.win.top = self.win.top + 2; self.win.bottom = self.win.bottom - 2 end + if self.t >= 40 then + self:emit(1, 2, 7, 8, 9, 11) + se(self, "SEQ_SE_DP_W060C") -- evolution.c:380 + self.growState, self.delta = 0x10, 8 + self.phase, self.t = 'alternate', 0 + end + elseif p == 'alternate' then + if self.cancelable and self.game.input and self.game.input:wasPressed('b') then return self:stop() end + if not self:particlesActive() then + self:emit(3, 4, 5, 6, 10) + self:fade(16, 2, 'white') + self.scale = { 0, 256 } + se(self, "SEQ_SE_DP_W062") -- evolution.c:398 + self.growState = nil + self.phase, self.t, self.delay = 'clampOut', 0, 8 + end + elseif p == 'clampOut' then + if self.win.top > 0 then self.win.top = self.win.top - 2; self.win.bottom = self.win.bottom + 2 end + if not self:fadeBusy() then + self.delay = self.delay - 1 + if self.delay <= 0 then + self:emit(12) + self:fade(0, 4, 'white') + self:spriteFade(16, 0, 3) + se(self, "SEQ_SE_DP_W080") -- evolution.c:415 + self.phase, self.t = 'reveal', 0 + end + end + elseif p == 'reveal' then + if not self:fadeBusy() and not self:particlesActive() then + require('src.core.Sound').playCry(self.game.data, self.newSpecies) + self.phase = 'done' + self:finish() + end + elseif p == 'cancel' then + if not self:fadeBusy() and self.t > 1 and not self.canceledBack then + self.canceledBack = true + self.scale = { 256, 0 } + self.growState = nil + self.systems = {} + self.win = { top = 0, bottom = 160 } + self:fade(0, 0, 'white') + self:spriteFade(16, 0, 0) + require('src.core.Music').restoreMap(self.game.data) + elseif self.canceledBack and not self:fadeBusy() and not self.stoppedSaid then + self.stoppedSaid = true + require('src.core.Sound').playCry(self.game.data, self.mon.species) + self.phase = 'done' + self:say('stopped', function() + self.game.stack:pop() + if self.onDone then self.onDone() end + end) + end + end +end + +-- ------------------------------------------------------------------ draw -- + +local tintShader +local function tint() + if tintShader == nil then + local ok, sh = pcall(love.graphics.newShader, [[ + extern vec3 target; + extern float amount; + vec4 effect(vec4 colour, Image tex, vec2 uv, vec2 sc) { + vec4 p = Texel(tex, uv); + return vec4(mix(p.rgb, target, amount), p.a) * colour; + } + ]]) + tintShader = ok and sh or false + end + return tintShader or nil +end + +function State:drawMon(img, scale256, level, colour) + if not img or scale256 <= 0 then return end + local g = love.graphics + local w, h = img:getDimensions() + local fw = math.min(w, 80) + self.quads = self.quads or {} + local q = self.quads[img] + if not q then q = g.newQuad(0, 0, fw, math.min(h, 80), w, h); self.quads[img] = q end + local s = scale256 / 256 + local sh = level > 0 and tint() + local prev = g.getShader() + if sh then + sh:send('target', colour == 'black' and { 0, 0, 0 } or { 1, 1, 1 }) + sh:send('amount', level / 16) + g.setShader(sh) + end + g.setColor(1, 1, 1, 1) + g.draw(img, q, MON_X, MON_Y, 0, s, s, fw / 2, math.min(h, 80) / 2) + g.setShader(prev) +end + +function State:drawParticles() + local g = love.graphics + self.particleImages = self.particleImages or {} + for _, sys in ipairs(self.systems) do + -- drawn as Gen4Battle.drawParticles draws a move's: the texture at its own + -- size times the particle's two scales, from its centre, tiled when the + -- emitter repeats it, modulated by the particle's colour + sys:draw(128, 96, function(texture, x, y, sx, sy, alpha, rotation, colour, tileS, tileT) + local rec = self.particleArt[texture + 1] + if not rec or not rec.path then return end + local img = self.particleImages[rec.path] + if img == nil then img = image(rec.path) or false; self.particleImages[rec.path] = img end + if not img then return end + local w, h = img:getDimensions() + local c = colour or { 255, 255, 255 } + local a = math.max(0, math.min(1, tonumber(alpha) or 1)) + g.setColor(c[1] / 255, c[2] / 255, c[3] / 255, a) + tileS = math.max(1, math.floor(tonumber(tileS) or 1)) + tileT = math.max(1, math.floor(tonumber(tileT) or 1)) + if tileS > 1 or tileT > 1 then + -- a flipped repeat mirrors every other copy (the DS texture's + -- flipS/flipT), which is how a quarter-arc texture becomes a ring + local tex = self.textureBits[texture + 1] or {} + img:setWrap(tex.flipS and 'mirroredrepeat' or 'repeat', tex.flipT and 'mirroredrepeat' or 'repeat') + local key = w .. ':' .. h .. ':' .. tileS .. ':' .. tileT + self.tileQuads = self.tileQuads or {} + local q = self.tileQuads[key] + if not q then q = g.newQuad(0, 0, w * tileS, h * tileT, w, h); self.tileQuads[key] = q end + g.draw(img, q, x, y, rotation or 0, (sx or 1) / tileS, (sy or 1) / tileT, w * tileS / 2, h * tileT / 2) + else + g.draw(img, x, y, rotation or 0, sx or 1, sy or 1, w / 2, h / 2) + end + end) + end + g.setColor(1, 1, 1, 1) +end + function State:draw() local g = love.graphics - g.setColor(0,0,0,1); g.rectangle('fill',0,0,256,192); g.setColor(1,1,1,1) - if self.background then g.draw(self.background,0,0) end - local image, scale = self.oldSprite, 1 - if self.phase == 'morph' then - -- Alternating silhouettes compress and expand; effect timing is reconstructed. - local p = math.min(1,self.t/150) - local period = math.max(4,math.floor(24-20*p)) - local beat = self.t % period / period - image = beat < 0.5 and (self.oldWhite or self.oldSprite) or (self.newWhite or self.newSprite) - scale = 0.1 + 0.9 * math.abs(beat*2-1) - elseif self.phase == 'flash' then - g.setColor(1,1,1,1);g.rectangle('fill',0,0,256,192);return - elseif self.phase == 'done' then image = self.canceled and self.oldSprite or self.newSprite end - if image then - local previous=g.getShader() - if self.phase=='morph' then - self.whiteShader=self.whiteShader or g.newShader([[vec4 effect(vec4 colour,Image tex,vec2 uv,vec2 screen){return vec4(1.0,1.0,1.0,Texel(tex,uv).a)*colour;}]]) - g.setShader(self.whiteShader) - end - local w,h=image:getDimensions();g.draw(image,128,88,0,scale,scale,w/2,h/2) - g.setShader(previous) - end - g.setColor(1,1,1,1) + local f = self.bgFade + local amount = f.level / 16 + local fc = f.colour == 'black' and { 0, 0, 0 } or { 1, 1, 1 } + local b = self.backdrop + local function mixed(c) return c[1] / 255 * (1 - amount) + fc[1] * amount, c[2] / 255 * (1 - amount) + fc[2] * amount, + c[3] / 255 * (1 - amount) + fc[3] * amount end + -- the backdrop (outside window 0 and under everything) + g.setColor(mixed(b)); g.rectangle('fill', 0, 0, W, H) + -- window 0: BG3 and the 3D layer + local top, bottom = self.win.top, self.win.bottom + local sx, sy = g.transformPoint(0, top) + local ex, ey = g.transformPoint(W, bottom) + local old = { g.getScissor() } + g.intersectScissor(sx, sy, math.max(0, ex - sx), math.max(0, ey - sy)) + if self.background then g.setColor(1, 1, 1, 1); g.draw(self.background, 0, 0) end + if amount > 0 then g.setColor(fc[1], fc[2], fc[3], amount); g.rectangle('fill', 0, 0, W, H) end + local s = self.sprite or { level = 0 } + local colour = self.spriteColour or 'white' + if self.canceled or self.phase == 'opening' or self.phase == 'delay' then + self:drawMon(self.oldSprite, self.scale[1], s.level, colour) + else + self:drawMon(self.oldSprite, self.scale[1], s.level, colour) + self:drawMon(self.newSprite, self.scale[2], s.level, colour) + end + self:drawParticles() + if #old > 0 then g.setScissor(unpack(old)) else g.setScissor() end + -- the message window stays up under the scene's text (BG1, outside the + -- window's reach and outside the fade's palettes) + if self.box then pcall(self.box.draw, self.box) end + g.setColor(1, 1, 1, 1) + local SS = require('src.ui.SecondScreen') + if self.bottom and SS.mode and SS.mode(self.game) ~= 'off' and SS.draw then + SS.draw(self.game, function() + g.setColor(1, 1, 1, 1) + g.draw(self.bottom, 0, 0) + end) + end end + return State diff --git a/src/ui/Gen4HgssStarterSelect.lua b/src/ui/Gen4HgssStarterSelect.lua new file mode 100644 index 000000000..ed8af9b05 --- /dev/null +++ b/src/ui/Gen4HgssStarterSelect.lua @@ -0,0 +1,342 @@ +-- Professor Elm's turntable: HeartGold and SoulSilver's starter choice. +-- +-- Everything on this screen is the cartridge's own, read from pokeheartgold's +-- choose_starter_app.c and drawn from `choose_starter_main_res` (/a/0/8/2), +-- which the models stage extracts as the `hgssStarter` set: +-- +-- tt_tabletop the table, which does not turn +-- tt_turntable the turntable, turned by `rotationAngle` +-- tt_ball one ball (nodes a / b / c), drawn three times +-- tt_ball_ef the burst when the chosen ball opens +-- animations ball_rock (the chosen ball rocking), tt_ball (opening), +-- tt_ball_ef (the burst) +-- +-- THE LAYOUT. `initBallModelPositions` stands the three balls at 0, 120 and +-- 240 degrees round the turntable at (0, 14, 32) rotated -- the selected one +-- always at 0, the front -- and LEFT / RIGHT turn the whole turntable 120 +-- degrees at FX16_CONST(0.5) a frame (`updateBaseRotation`, eleven frames) +-- before the next ball becomes the front one. +-- +-- THE CAMERA. `initCameraPosition`: target (0, 15, 0) shifted by (0, 0, 14), +-- distance 100, angle.x 0xDCC0 (-49.57 degrees), perspective 0x11A4 (24.8 +-- degrees, a HALF angle -- see Gen4StarterSelect for why), clip 4..256. +-- Confirming moves it over 8 steps to the IN path: -30.76 degrees, 22.7, +-- target z 12, distance 60; backing out moves it back. +-- +-- THE WORDS are message bank 190: 0 the opening line, 4..6 the ball's +-- contents when it is inspected, 1..3 Elm's question, 7 the prompt. +-- +-- WHAT THIS FILE INVENTS: where the species picture sits. The cartridge +-- shows it on the other screen (`screensFlipped`); here it sits above the +-- message box beside the 3D, which is the closest one screen allows. + +local Font = require("src.render.Font") +local Gen4Anim = require("src.import.Gen4Anim") +local Gen4Model = require("src.render.Gen4Model") +local Logger = require("src.core.Logger") +local Sprites = require("src.pokemon.Sprites") + +local Screen = {} +Screen.__index = Screen +Screen.isOpaque = true + +local W, H = 256, 192 +local BOX = { tx = 1, ty = 18, tw = 29, th = 6 } +local TEXT_X, TEXT_Y, LINE_H = 16, 152, 16 + +local function deg(d) return math.rad(d) end +local CAM_OUT = { pitch = deg(-49.57), fov = deg(24.805) * 2, z = 14, distance = 100 } +local CAM_IN = { pitch = deg(-30.76), fov = deg(22.7) * 2, z = 12, distance = 60 } +local CAM_STEPS = 8 +local TARGET_Y = 15 +local NEAR, FAR = 4, 256 + +local BALL_RADIUS, BALL_Y = 32, 14 +local TURN = deg(120) +-- FX16_CONST(0.5) on a u16 angle is 2048 of 65536 a frame +local TURN_SPEED = 2048 / 65536 * 2 * math.pi +local BALL_YAW_IN = deg(-30.76) +local FADE_FRAMES = 16 + +local FLIP_Y = { 1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 } + +local function multiply(a, b) + local out = {} + for row = 0, 3 do + for col = 0, 3 do + local sum = 0 + for k = 0, 3 do sum = sum + a[row * 4 + k + 1] * b[k * 4 + col + 1] end + out[row * 4 + col + 1] = sum + end + end + return out +end + +local function rotY(a) + local c, s = math.cos(a), math.sin(a) + return { c, 0, s, 0, 0, 1, 0, 0, -s, 0, c, 0, 0, 0, 0, 1 } +end + +local function translation(x, y, z) + return { 1, 0, 0, x, 0, 1, 0, y, 0, 0, 1, z, 0, 0, 0, 1 } +end + +local function named(list, name) + for _, item in ipairs(list or {}) do + if item.name == name then return item end + end + return nil +end + +local function tracksOf(set, name) + local animation = named(set.animations, name) + if not (animation and animation.tracks) then return nil end + local byNode = {} + for _, track in ipairs(animation.tracks) do byNode[track.index] = track end + return { tracks = byNode, frames = math.max(1, animation.frames or 1) } +end + +function Screen:uiSize() return W, H end +function Screen:wantsFillScale() return true end +function Screen:wantsEdgeBleed() return false end + +function Screen.new(game, opts) + opts = opts or {} + local self = setmetatable({}, Screen) + self.game = game + self.species = opts.species or { 152, 155, 158 } + self.onChoose, self.onCancel = opts.onChoose, opts.onCancel + self.selection = 0 -- curSelection: the ball at the front + self.base = 0 -- rotationAngle + self.turning = 0 -- rotationSpeed's sign while turning + self.camStep = 0 -- 0 = OUT, CAM_STEPS = IN + self.camGoal = 0 + self.ballYaw = 0 + self.frame = 0 + self.state = "intro" -- intro, browse, inspect, turning, confirm, opening, fading + + local data = game.data or {} + self.text = data.text or {} + local set = ((data.gen4_models or {}).sets or {}).hgssStarter + if not set then + Logger.warn("gen4 hgss starter: this cache carries no turntable models (re-import to get them)") + self.unavailable = true + return self + end + self.tabletop = Gen4Model.new(named(set.models, "tt_tabletop")) + self.turntable = Gen4Model.new(named(set.models, "tt_turntable")) + self.ball = Gen4Model.new(named(set.models, "tt_ball")) + self.burst = Gen4Model.new(named(set.models, "tt_ball_ef")) + if not (self.tabletop and self.turntable and self.ball) then + Logger.warn("gen4 hgss starter: the turntable models would not build") + self.unavailable = true + return self + end + self.rock = tracksOf(set, "ball_rock") + self.open = tracksOf(set, "tt_ball") + self.burstAnim = tracksOf(set, "tt_ball_ef") + self:say(0) + return self +end + +-- ------------------------------------------------------------------- text -- + +function Screen:line(entry) + local s = self.text[("TEXT_B0190_%05d"):format(entry)] or "" + return (s:gsub("{COLOR %d+}", "")) +end + +function Screen:say(entry) + local text = self:line(entry) + local pages = {} + for page in (text .. "\v"):gmatch("([^\v\f]*)[\v\f]") do + page = page:gsub("^%s+", ""):gsub("%s+$", "") + if page ~= "" then pages[#pages + 1] = page end + end + self.pages, self.page = (#pages > 0 and pages or { "" }), 1 +end + +-- ------------------------------------------------------------------- flow -- + +function Screen:finish(chosen) + self.game.stack:pop() + if chosen then + if self.onChoose then self.onChoose(self.species[self.selection + 1], self.selection) end + elseif self.onCancel then + self.onCancel() + end +end + +function Screen:inspect() + self.state = "inspect" + self:say(4 + self.selection) +end + +function Screen:update() + if self.unavailable then return self:finish(false) end + self.frame = self.frame + 1 + -- the camera path, eight steps either way + if self.camStep < self.camGoal then self.camStep = self.camStep + 1 + elseif self.camStep > self.camGoal then self.camStep = self.camStep - 1 end + local towards = self.camGoal > 0 and BALL_YAW_IN or 0 + self.ballYaw = self.ballYaw + (towards - self.ballYaw) / 3 + + if self.state == "turning" then + self.base = self.base + self.turning * TURN_SPEED + if math.abs(self.base) >= TURN then + -- reinitBallModelPosInDirection: turning left brings the next ball round + self.selection = (self.selection + (self.turning < 0 and 1 or 2)) % 3 + self.base, self.turning = 0, 0 + self:inspect() + end + return + end + if self.state == "opening" then + self.openFrame = (self.openFrame or 0) + 1 + if self.openFrame >= (self.open and self.open.frames or 30) * 0.5 then + self.state, self.fade = "fading", 0 + end + return + end + if self.state == "fading" then + self.openFrame = self.openFrame + 1 + self.fade = self.fade + 1 + if self.fade >= FADE_FRAMES then self:finish(true) end + return + end + + local input = self.game.input + if not input then return end + local a, b = input:wasPressed("a"), input:wasPressed("b") + local left, right = input:wasPressed("left"), input:wasPressed("right") + if a and self.page < #self.pages then self.page = self.page + 1 return end + + if self.state == "intro" then + if a then self.state = "browse"; self:say(7) end + elseif self.state == "browse" or self.state == "inspect" then + if left or right then + self.state, self.turning = "turning", left and -1 or 1 + elseif a then + if self.state == "browse" then self:inspect() + else + self.state, self.camGoal = "confirm", CAM_STEPS + self:say(1 + self.selection) + end + end + elseif self.state == "confirm" then + if a then + self.state, self.openFrame = "opening", 0 + elseif b then + self.state, self.camGoal = "browse", 0 + self:say(7) + end + end +end + +-- ------------------------------------------------------------------- draw -- + +local function poseFrom(model, anim, frame) + if not (model and anim) then return nil end + local f = math.floor(frame) % anim.frames + return model:posed(function(node) + local track = anim.tracks[node] + return track and Gen4Anim.unpackFrame(track.matrices, f) or nil + end) +end + +function Screen:drawScene() + if not self.colour then + self.colour, self.depth = Gen4Model.newTarget(W, H) + if not self.colour then self.noDepth = true end + end + if self.noDepth then return nil end + local g = love.graphics + local previous = { g.getCanvas() } + g.setCanvas({ self.colour, depthstencil = self.depth }) + g.clear(0, 0, 0, 0, true, true) + + local k = self.camStep / CAM_STEPS + local function lerp(p, q) return p + (q - p) * k end + local projection = Gen4Model.perspective(lerp(CAM_OUT.fov, CAM_IN.fov), W / H, NEAR, FAR) + local view = Gen4Model.orbit({ 0, TARGET_Y, lerp(CAM_OUT.z, CAM_IN.z) }, + lerp(CAM_OUT.distance, CAM_IN.distance), 0, + lerp(CAM_OUT.pitch, CAM_IN.pitch)) + local vp = multiply(FLIP_Y, multiply(projection, view)) + + self.tabletop:draw(vp) + local base = rotY(self.base) + local turned = multiply(vp, base) + self.turntable:draw(turned) + + local rocking = self.state == "inspect" or self.state == "confirm" or self.state == "turning" + for slot = 0, 2 do + local angle = slot * TURN + local m = multiply(turned, multiply(rotY(angle), + translation(0, BALL_Y, BALL_RADIUS))) + local pose + if slot == 0 then + m = multiply(m, rotY(self.ballYaw)) + if self.state == "opening" or self.state == "fading" then + pose = poseFrom(self.ball, self.open, math.min(self.openFrame or 0, (self.open and self.open.frames or 1) - 1)) + elseif rocking and self.state ~= "turning" then + pose = poseFrom(self.ball, self.rock, self.frame) + end + end + self.ball:draw(m, pose) + end + if self.burst and (self.state == "opening" or self.state == "fading") then + local m = multiply(turned, translation(0, BALL_Y, BALL_RADIUS)) + self.burst:draw(m, poseFrom(self.burst, self.burstAnim, self.openFrame or 0)) + end + + g.setCanvas(previous[1] and previous or nil) + return self.colour +end + +function Screen:image(path) + if type(path) ~= "string" or path == "" then return nil end + self.pics = self.pics or {} + if self.pics[path] == nil then + local ok, img = pcall(require("src.render.Assets").image, path) + self.pics[path] = ok and img or false + if self.pics[path] then self.pics[path]:setFilter("nearest", "nearest") end + end + return self.pics[path] or nil +end + +function Screen:draw() + local g = love.graphics + g.setColor(0, 0, 0, 1) + g.rectangle("fill", 0, 0, W, H) + g.setColor(1, 1, 1, 1) + local scene = not self.unavailable and self:drawScene() + if scene then g.draw(scene, 0, 0) end + + -- the species, once its ball is inspected (the cartridge's other screen) + if self.state == "inspect" or self.state == "confirm" then + local species = self.species[self.selection + 1] + local pic = species and self:image(Sprites.path(self.game.data, species, "front", { kind = "summary" })) + if pic then + local pw, ph = pic:getDimensions() + local s = 48 / math.max(pw, ph) + g.draw(pic, 8, 8, 0, s, s) + end + end + + Font.drawDialogueBox(BOX.tx, BOX.ty, BOX.tw, BOX.th) + local page = self.pages and self.pages[self.page] or "" + local y = TEXT_Y + for line in (tostring(page) .. "\n"):gmatch("([^\n]*)\n") do + if y >= TEXT_Y + LINE_H * 2 then break end + Font.draw(line, TEXT_X, y) + y = y + LINE_H + end + + if self.state == "fading" then + g.setColor(1, 1, 1, math.min(1, self.fade / FADE_FRAMES)) + g.rectangle("fill", 0, 0, W, H) + end + g.setColor(1, 1, 1, 1) +end + +return Screen diff --git a/src/ui/Gen4MiningScreen.lua b/src/ui/Gen4MiningScreen.lua index ca4ab04eb..e5f66d4ce 100644 --- a/src/ui/Gen4MiningScreen.lua +++ b/src/ui/Gen4MiningScreen.lua @@ -32,6 +32,14 @@ Gen4MiningScreen.name = "Gen4MiningScreen" local W, H = 256, 192 +-- A cartridge sound effect (src/underground/mining.c's Sound_PlayEffect calls), +-- by its SDAT symbol name. Nothing happens headless or without audio. +local function se(self, name, opts) + local data = self.game and self.game.data + local ok, Sound = pcall(require, "src.core.Sound") + if ok and type(Sound) == "table" and Sound.playSe and data then return Sound.playSe(data, name, opts) end +end + -- A mining cell is 2x2 tiles and the grid starts four tile rows down, which is -- the cartridge's own arithmetic read backwards: -- @@ -417,6 +425,7 @@ function Gen4MiningScreen:chooseTool(tool) local other = self.pickaxe and "hammer" or "pickaxe" self.buttons[tool], self.buttons[other] = "mid", "up" self.pendingPress = tool + se(self, "SEQ_SE_DP_SELECT") -- SE_CONFIRM_sseq_3 if self.pickaxe then self:setSprite("button", 7, 232, 152) else self:setSprite("button", 6, 232, 80) end end @@ -427,6 +436,9 @@ function Gen4MiningScreen:playHit(cx, cy, hit) self:setSprite("tool", self.pickaxe and 0 or 1, x, y) local impact = hit.hitRock and 2 or (self.pickaxe and 3 or 4) self:setSprite("impact", impact, x, y) + if hit.hitRock then se(self, "SEQ_SE_DP_UG_004") + elseif self.pickaxe then se(self, "SEQ_SE_DP_UG_002") + else se(self, "SEQ_SE_DP_UG_003") end if hit.foundItem then self:setSprite("sparkle", 5, x, y) end end @@ -438,6 +450,7 @@ function Gen4MiningScreen:sparkleNewlyOut() for i, placed in ipairs(MiningWall.treasures(self.wall)) do if out[i] and not self.out[i] then self.out[i] = true + se(self, "SEQ_SE_DP_KIRAKIRA4") for j = 0, 2 do local x = self.rand(placed.obj.w * 8) + placed.x * CELL local y = self.rand(placed.obj.h * 8) + placed.y * CELL + GRID_Y @@ -500,7 +513,10 @@ function Gen4MiningScreen:touchpressed(_, px, py) if hit then self.lastHit = { x = cx, y = cy, rock = hit.hitRock, time = 12 } self:playHit(cx, cy, hit) - self.crackShown = true -- Mining_DrawWallCrack places it + -- Mining_RemoveDirt: uncovering part of a treasure arms soundEffectTimer + -- (15); Mining_MainGameLoop plays SEQ_SE_DP_KIRAKIRA3 when it runs out + if hit.foundItem then self.kiraTimer = 15 end + self.crackShown = true -- Mining_DrawWallCrack places it self.shakeTimer = 1 end self:sparkleNewlyOut() @@ -542,6 +558,8 @@ function Gen4MiningScreen:update() self:shakeScreen() if self.phase == "intro" then + -- MINING_STATE_WAIT_FOR_FADE_IN_START: the ping as the wall appears + if not self.introSe then self.introSe = true; se(self, "SEQ_SE_PL_UG_006") end self.timer = self.timer - 1 if self.timer <= 0 then self.message = nil @@ -560,6 +578,7 @@ function Gen4MiningScreen:update() if self.timer > 0 then self.timer = self.timer - 1 if self.timer == 0 then + se(self, "SEQ_SE_DP_PIRORIRO2") local list = { { text = self:text("everything") or Strings("Everything was dug up!"), hold = 60 } } self:say(self:obtainedMessages(list), function() self:close("won") end) end @@ -576,6 +595,7 @@ function Gen4MiningScreen:update() self.shakeTimer = 100 self.shakeX, self.shakeY = 0, 0 self.fade = 0 -- 15 frames down to black + se(self, "SEQ_SE_DP_UG_001") -- MINING_STATE_COLLAPSE_FADE_OUT end return end @@ -599,6 +619,10 @@ function Gen4MiningScreen:update() self.pendingPress = nil end self:queueShake() + if self.kiraTimer and self.kiraTimer > 0 then + self.kiraTimer = self.kiraTimer - 1 + if self.kiraTimer == 0 then se(self, "SEQ_SE_DP_KIRAKIRA3") end + end -- THE PORT'S OWN WAY OUT. The cartridge has none: once the game starts you -- play it to a win or a collapse. A player here may have no touch device at -- all, or a stuck screen, and leaving them in a state with no exit is worse diff --git a/src/ui/Gen4NamingScreen.lua b/src/ui/Gen4NamingScreen.lua index be7fd9293..8b55ede89 100644 --- a/src/ui/Gen4NamingScreen.lua +++ b/src/ui/Gen4NamingScreen.lua @@ -150,6 +150,14 @@ local ENTRY_Y = 32 local FALLBACK_BG = { 0.24, 0.27, 0.36 } local FALLBACK_ALT = { 0.19, 0.22, 0.30 } +-- THE CARTRIDGE'S SOUND EFFECTS (naming_screen.c's Sound_PlayEffect calls), +-- by their SDAT names: SE_CONFIRM_sseq_3 is SEQ_SE_DP_SELECT. +local function se(self, name, opts) + local data = self.game and self.game.data + local ok, Sound = pcall(require, "src.core.Sound") + if ok and type(Sound) == "table" and Sound.playSe and data then return Sound.playSe(data, name, opts) end +end + function Gen4NamingScreen:uiSize() return W, H end function Gen4NamingScreen:wantsFillScale() return true end function Gen4NamingScreen:wantsEdgeBleed() return false end @@ -276,6 +284,7 @@ function Gen4NamingScreen:press(cell) -- cartridge's CHAR_SPACE cells do in the middle of a page. if cell.value ~= "" and #self.glyphs < self.maxLen then self.glyphs[#self.glyphs + 1] = cell.value + se(self, "SEQ_SE_DP_BOX02") -- ns.c 3019 -- the cursor's pop (seq 60): a dot swelling and shrinking, 2 ticks a -- frame; then, if the name is full, the cursor goes to OK self.pop = { tick = 0, x = select(1, self:cursorPlace()), y = select(2, self:cursorPlace()) } @@ -291,20 +300,25 @@ function Gen4NamingScreen:press(cell) if #self.glyphs > 0 then self.glyphs[#self.glyphs] = nil self.pressed = { id = "back", tick = 0 } + se(self, "SEQ_SE_DP_SELECT") -- ns.c 2970 end elseif id == "ok" then self.pressed = { id = "ok", tick = 0 } + se(self, "SEQ_SE_DP_PIRORIRO") -- ns.c 2979 self:close(self:typed()) end end -- A page change (ns.c 2213-2265): the new panel slides in from the left, 24 -- pixels a 30 Hz frame, the old one drops away 10 a frame, and when the new --- one lands the overlay and its buttons wiggle. +-- one lands the overlay and its buttons wiggle. The turn whooshes +-- (SEQ_SE_DP_SYU03, ns.c 1301/1309 for SELECT, 2931 for a tab) and the landing +-- clicks (SEQ_SE_DP_NAMEIN_01, ns.c 2263, in tickTimers). function Gen4NamingScreen:turnTo(page) if page == self.page then return end self.slide = { from = self.page, frame = 0 } self.page = page + se(self, "SEQ_SE_DP_SYU03") end -- THE MOVE (NamingScreen_MoveCursor, ns.c 2489-2526): the grid wraps; a step @@ -353,6 +367,7 @@ function Gen4NamingScreen:tickTimers(dt) if 238 - 24 * self.slide.frame <= -1 then self.slide = nil self.wiggle = 0 + se(self, "SEQ_SE_DP_NAMEIN_01") -- ns.c 2263 end elseif self.wiggle then self.wiggle = self.wiggle + 1 @@ -377,21 +392,30 @@ function Gen4NamingScreen:update(dt) return end -- THE CURSOR STARTS HIDDEN, and the first d-pad or A press only shows it - -- (ns.c 2584-2587, 2854-2857) + -- (ns.c 2584-2587, 2854-2857). A d-pad press clicks even then, because + -- ProcessDirectionInputs plays SE_CONFIRM before it looks (ns.c 2539-2560); + -- A shows the cursor silently. if not self.cursorShown then for _, k in ipairs({ "left", "right", "up", "down", "a" }) do - if input:wasPressed(k) then self.cursorShown = true; self:placed(); return end + if input:wasPressed(k) then + if k ~= "a" then se(self, "SEQ_SE_DP_SELECT") end + self.cursorShown = true; self:placed(); return + end + end + end + -- every d-pad step clicks (ns.c 2539-2560) + for _, step in ipairs({ { "left", -1, 0 }, { "right", 1, 0 }, { "up", 0, -1 }, { "down", 0, 1 } }) do + if input:wasPressed(step[1]) then + se(self, "SEQ_SE_DP_SELECT") + self:move(step[2], step[3]) end end - if input:wasPressed("left") then self:move(-1, 0) end - if input:wasPressed("right") then self:move(1, 0) end - if input:wasPressed("up") then self:move(0, -1) end - if input:wasPressed("down") then self:move(0, 1) end if input:wasPressed("a") then self:press(self:cell(self.row, self.col)) end if input:wasPressed("b") then self:press({ kind = "button", value = "back" }) end if input:wasPressed("select") then self:turnTo(self.page % 3 + 1) end - -- START goes to OK (12, 0) rather than confirming + -- START goes to OK (12, 0) rather than confirming, with a click (ns.c 2568) if input:wasPressed("start") then + se(self, "SEQ_SE_DP_SELECT") self.cursorShown = true self.row, self.col = 1, 12 self:placed() diff --git a/src/ui/Gen4Options.lua b/src/ui/Gen4Options.lua index 13a19b783..0ef5a472d 100644 --- a/src/ui/Gen4Options.lua +++ b/src/ui/Gen4Options.lua @@ -72,6 +72,13 @@ local VISIBLE = 7 local FRAME_TYPES = 20 +-- options_menu.c's effects: SE_CONFIRM_sseq_3 is the ROM's SEQ_SE_DP_SELECT. +local function se(self, name, opts) + local data = self.game and self.game.data or self.data + local ok, Sound = pcall(require, "src.core.Sound") + if ok and type(Sound) == "table" and Sound.playSe and data then return Sound.playSe(data, name, opts) end +end + function Gen4Options:uiSize() return W, H end function Gen4Options:wantsFillScale() return true end function Gen4Options:wantsEdgeBleed() return false end @@ -182,6 +189,12 @@ function Gen4Options.new(game, opts) if #self.rows == 0 then Logger.warn("gen4 options: this dataset carries no OPTION vocabulary") end + -- what the cartridge rows held on entry, for ChangesWereMade + self.opened = {} + local options = (game.save and game.save.options) or {} + for _, row in ipairs(self.rows) do + if row.binding then self.opened[row.binding.field] = options[row.binding.field] end + end return self end @@ -239,7 +252,20 @@ function Gen4Options:valueText(row) return values[self:choiceOf(row)] or values[1] or "" end +function Gen4Options:changesWereMade() + local options = (self.game.save and self.game.save.options) or {} + for field, was in pairs(self.opened or {}) do + if options[field] ~= was then return true end + end + return false +end + +-- OptionsMenu_Main STATE_HANDLE_INPUT: leaving with nothing changed plays +-- SE_CONFIRM_sseq_3 (options_menu.c:240). With changes the cartridge asks +-- first and plays SEQ_SE_DP_SAVE on YES (options_menu.c:271); this screen +-- applies each change as it is made and has no prompt, so that path is silent. function Gen4Options:close() + if not self:changesWereMade() then se(self, "SEQ_SE_DP_SELECT") end self.game.stack:pop() if self.onCancel then self.onCancel() end end @@ -262,20 +288,24 @@ function Gen4Options:update() if not input then return end local n = #self.rows + 1 -- the rows, then CLOSE local row = self.rows[self.index] + -- ProcessMainInput: every cursor move and every choice change plays + -- SE_CONFIRM_sseq_3 (options_menu.c:894/898/910/919); CLOSE has no choices if input:wasPressed("down") then self.index = self.index % n + 1 + se(self, "SEQ_SE_DP_SELECT") elseif input:wasPressed("up") then self.index = (self.index - 2) % n + 1 + se(self, "SEQ_SE_DP_SELECT") elseif input:wasPressed("right") then - self:cycle(row, 1) + if row then self:cycle(row, 1); se(self, "SEQ_SE_DP_SELECT") end elseif input:wasPressed("left") then - self:cycle(row, -1) + if row then self:cycle(row, -1); se(self, "SEQ_SE_DP_SELECT") end elseif input:wasPressed("a") then if self.index > #self.rows then return self:close() end if row and row.engine and row.engine.activate then return pcall(row.engine.activate, self.game) end - self:cycle(row, 1) + if row then self:cycle(row, 1); se(self, "SEQ_SE_DP_SELECT") end elseif input:wasPressed("b") or input:wasPressed("start") then self:close() end diff --git a/src/ui/Gen4PoffinCase.lua b/src/ui/Gen4PoffinCase.lua index 9ca2d545f..7a90b2764 100644 --- a/src/ui/Gen4PoffinCase.lua +++ b/src/ui/Gen4PoffinCase.lua @@ -32,6 +32,16 @@ Case.BANK = 463 Case.VIEW = 6 -- POFFIN_LIST_VIEW_HEIGHT Case.ALL = 5 -- FLAVOR_MAX: the ALL button +-- THE CASE'S SOUNDS (manager.c, menus.c): SEQ_SE_DP_SELECT (SE_CONFIRM_sseq_3) +-- for every A / B and every cursor move of the list (UpdateSelectedPoffin), +-- the action menu (PlaySound) and the YES / NO window (Menu_ProcessInput); +-- SEQ_SE_DP_BUTTON9 for a flavour button (PoffinManager_UpdatePoffinFilter). +local function se(self, name, opts) + local data = self.game and self.game.data + local ok, Sound = pcall(require, "src.core.Sound") + if ok and type(Sound) == "table" and Sound.playSe and data then return Sound.playSe(data, name, opts) end +end + function Case:uiSize() return 256, 192 end function Case:wantsFillScale() return true end @@ -116,6 +126,8 @@ function Case:close() end function Case:setFilter(f) + -- pressed again on the chosen button, it still sounds + se(self, "SEQ_SE_DP_BUTTON9") if f == self.filter then return end self.filter, self.top, self.cursor = f, 0, 0 self:refresh() @@ -135,8 +147,13 @@ function Case:update() return end if self.mode == "confirm" then - if input:wasPressed("up") or input:wasPressed("down") then self.yes = not self.yes end + if input:wasPressed("up") or input:wasPressed("down") then + self.yes = not self.yes + se(self, "SEQ_SE_DP_SELECT") + end local a, b = input:wasPressed("a"), input:wasPressed("b") + -- State_ConfirmDeletePoffin: YES and MENU_CANCEL both sound + if a or b then se(self, "SEQ_SE_DP_SELECT") end if b or (a and not self.yes) then self.mode, self.message = "list", nil; return end if a then local index = self:selected() @@ -150,9 +167,11 @@ function Case:update() return end if self.mode == "action" then - if input:wasPressed("up") then self.action = (self.action + 2) % 3 end - if input:wasPressed("down") then self.action = (self.action + 1) % 3 end + if input:wasPressed("up") then self.action = (self.action + 2) % 3; se(self, "SEQ_SE_DP_SELECT") end + if input:wasPressed("down") then self.action = (self.action + 1) % 3; se(self, "SEQ_SE_DP_SELECT") end local a, b = input:wasPressed("a"), input:wasPressed("b") + -- State_SelectAction: A or B + if a or b then se(self, "SEQ_SE_DP_SELECT") end if b or (a and self.action == 2) then self.mode = "list"; return end if a and self.action == 0 then return self:give() end if a and self.action == 1 then @@ -165,6 +184,7 @@ function Case:update() end -- the list (a ListMenu with PAGER_MODE_LEFT_RIGHT_PAD) local count = #self.rows + 1 + local wasTop, wasCursor = self.top, self.cursor if input:wasPressed("up") then if self.cursor > 0 then self.cursor = self.cursor - 1 elseif self.top > 0 then self.top = self.top - 1 end elseif input:wasPressed("down") then @@ -180,7 +200,13 @@ function Case:update() elseif input:wasPressed("r") then self:setFilter((self.filter + 1) % 6) end + -- UpdateSelectedPoffin: the selection moved + if (self.top ~= wasTop or self.cursor ~= wasCursor) and not (input:wasPressed("l") or input:wasPressed("r")) then + se(self, "SEQ_SE_DP_SELECT") + end local a, b = input:wasPressed("a"), input:wasPressed("b") + -- State_SelectPoffin: A or B + if a or b then se(self, "SEQ_SE_DP_SELECT") end if b then return self:close() end if a then local index = self:selected() diff --git a/src/ui/Gen4PoffinCooking.lua b/src/ui/Gen4PoffinCooking.lua index 578ede31a..223d88897 100644 --- a/src/ui/Gen4PoffinCooking.lua +++ b/src/ui/Gen4PoffinCooking.lua @@ -39,6 +39,33 @@ Cook.isOpaque = true Cook.BANK = 464 Cook.FINGER_RADIUS = 40 +-- THE POT'S SOUNDS are overlay083's (ov83_0223B5A0.c), off each frame's stir +-- record (ov83_0223C3E8): +-- * a turn of the batter: NM01 / NM04_2 / NM07_2 by phase (ov83_0223C90C) +-- * an overflow: NM02 / NM05 by phase; a burn: NM03 / NM03 / NM08 -- the +-- first slow stretch's warning plays nothing (ov83_0223C6D4) +-- * a new direction drawn: NM12_2 (ov83_0223C758); the arrow coming up as +-- the batter turns the wrong way: NM12_2 (ov83_0223C558) +-- * a frame the group stirs in sync: NM11 (ov83_0223C7FC) +-- * the end: NM07_2, NM08, NM11, NM12_2 stopped and NM13 (ov83_0223BCEC) +-- * the results, two panels a second apart: NM14 each (ov83_0223BF74) +-- Not here: the Berries dropping into the pot (NM10 / NM10_2, ov83_0223B9EC; +-- NM09 as each lands, ov83_0223D6A8.c), which this screen does not animate. +local function se(self, name, opts) + local data = self.game and self.game.data + local ok, Sound = pcall(require, "src.core.Sound") + if ok and type(Sound) == "table" and Sound.playSe and data then return Sound.playSe(data, name, opts) end +end +local function stopSe(self, name) + local data = self.game and self.game.data + local ok, Sound = pcall(require, "src.core.Sound") + if ok and type(Sound) == "table" and Sound.stopSe and data then return Sound.stopSe(data, name) end +end + +local TURN_SE = { "SEQ_SE_DP_NM01", "SEQ_SE_DP_NM04_2", "SEQ_SE_DP_NM07_2" } +local OVERFLOW_SE = { "SEQ_SE_DP_NM02", "SEQ_SE_DP_NM05", "SEQ_SE_DP_NM05" } +local BURN_SE = { "SEQ_SE_DP_NM03", "SEQ_SE_DP_NM03", "SEQ_SE_DP_NM08" } + function Cook:uiSize() return 256, 192 end function Cook:wantsFillScale() return true end @@ -176,6 +203,39 @@ function Cook:start(item) self.fingerAngle = -math.pi / 2 self.fingerSpeed = 0 self.finger = nil + self.lastAngle, self.arrowState = self.stir.angle % 65536, 0 +end + +-- ov83_0223C3E8's sounds, after one frame of the stir (`before`: the record +-- going in) +function Cook:frameSounds(s, before) + local phase = math.max(1, math.min(3, s.phase)) + -- ov83_0223C90C: the batter's angle wrapped the way it spins + local angle = s.angle % 65536 + if (s.velocity >= 0 and self.lastAngle > angle) or (s.velocity < 0 and self.lastAngle < angle) then + se(self, TURN_SE[phase]) + end + self.lastAngle = angle + -- ov83_0223C758: a new draw of the direction (the countdown restarted) + local drew = s.dirCountdown > before.dirCountdown + if drew then + se(self, "SEQ_SE_DP_NM12_2") + self.arrowState = 0 + else + -- ov83_0223C558: the arrow for the way it should turn, once as it comes up + local spin = s.velocity >= 0 and 0 or 1 + if spin == s.dir or s.velocity == 0 then + self.arrowState = 0 + elseif self.arrowState ~= s.dir + 1 then + self.arrowState = s.dir + 1 + se(self, "SEQ_SE_DP_NM12_2") + end + end + -- ov83_0223C6D4 + if s.event == "overflow" then se(self, OVERFLOW_SE[phase]) end + if s.event == "burn" then se(self, BURN_SE[phase]) end + -- ov83_0223C7FC: a frame stirred in sync + if (s.syncFrames or 0) > (before.syncFrames or 0) then se(self, "SEQ_SE_DP_NM11") end end function Cook:update(dt) @@ -184,6 +244,15 @@ function Cook:update(dt) self.mode = "waiting" return self:chooseBerry() end + if self.mode == "result" and self.resultFrames then + -- ov83_0223BF74: the results come up a panel a second, NM14 with each + self.resultAcc = (self.resultAcc or 0) + (dt or 1 / 60) * 30 + while self.resultAcc >= 1 do + self.resultAcc = self.resultAcc - 1 + self.resultFrames = self.resultFrames + 1 + if self.resultFrames == 31 or self.resultFrames == 62 then se(self, "SEQ_SE_DP_NM14") end + end + end if self.mode == "closing" or self.mode == "result" then if input:wasPressed("a") or input:wasPressed("b") then if self.mode == "closing" then return self:finish() end @@ -203,6 +272,7 @@ end -- One 30 Hz frame of finger then batter. function Cook:frame(input) local s = self.stir + local before = { dirCountdown = s.dirCountdown, syncFrames = s.syncFrames } local arcIn, rawArc = 0, 0 if self.touch then local t = self.touch @@ -255,6 +325,7 @@ function Cook:frame(input) else Stir.step(s, arcIn) end + if not s.done then self:frameSounds(s, before) end local game = self.game if s.event == "overflow" then self.message = line(game, 4) elseif s.event == "warn" then self.message = line(game, 3) @@ -266,6 +337,13 @@ end function Cook:result() local game = self.game + -- ov83_0223BCEC: the stirring sounds cut, and the finishing one + stopSe(self, "SEQ_SE_DP_NM07_2") + stopSe(self, "SEQ_SE_DP_NM08") + stopSe(self, "SEQ_SE_DP_NM11") + stopSe(self, "SEQ_SE_DP_NM12_2") + se(self, "SEQ_SE_DP_NM13") + self.resultFrames, self.resultAcc = 0, 0 local f = game.data.gen4_berry_flavors[self.berry] local berries = { { item = self.berry, flavors = f.flavors, smoothness = f.smoothness } } for _, p in ipairs(self.partners or {}) do diff --git a/src/ui/Gen4Pokedex.lua b/src/ui/Gen4Pokedex.lua index 9dd2743c8..384dafc79 100644 --- a/src/ui/Gen4Pokedex.lua +++ b/src/ui/Gen4Pokedex.lua @@ -109,9 +109,37 @@ function Gen4Pokedex.new(game, opts) self.page = "list" self.scroll = 0 self.form = 0 + -- the list page's graphics coming up plays BUTTON01 (see listTransition) + self.listSePending = true return self end +-- ----------------------------------------------------------------- sound -- +-- The overlay's effects (src/applications/pokedex/*.c): +-- SEQ_SE_DP_DECIDE every button that does something: open an entry, open +-- or leave search, swap the dex, leave, a size/loop/ +-- search value that changes +-- SEQ_SE_DP_DENSI06 every step of the selection (list, wheel, entry page) +-- and every tab or form that changes +-- SEQ_SE_DP_BUTTON01 the list page's graphics entering or leaving +-- (ov21_021D6FB4, ov21_021D5AEC.c:1365) +-- SEQ_SE_DP_Z_SEARCH a search starting (pokedex_search.c:377) +-- NOT HERE: crysub.c's SEQ_SE_DP_KATI (the chorus/pan switch and the dial, +-- which this cry page does not have), the foreign-language entry buttons +-- (ov21_021E1924.c) and the area map's drag (ov21_021DDD2C.c:557). +local function se(self, name, opts) + local data = self.game and self.game.data or self.data + local ok, Sound = pcall(require, "src.core.Sound") + if ok and type(Sound) == "table" and Sound.playSe and data then return Sound.playSe(data, name, opts) end +end + +-- The list page's enter/exit (ov21_021D71A8 / ov21_021D7248) both open with +-- ov21_021D6FB4's BUTTON01 -- a frame after the press's DECIDE, which is why +-- it waits for the next update. +function Gen4Pokedex:listTransition() + self.listSePending = true +end + -- ------------------------------------------------------------------ data -- -- Prefer the ROM's Sinnoh order until the National Dex is granted. Old @@ -220,6 +248,10 @@ function Gen4Pokedex:close() if self.wheelCanvas and self.wheelCanvas.release then self.wheelCanvas:release() end self.wheelCanvas=nil self:stopCry() + -- ov21_021D601C: B on the unfiltered list (ov21_021D5AEC.c:548), then the + -- list's exit + se(self, "SEQ_SE_DP_DECIDE") + se(self, "SEQ_SE_DP_BUTTON01") self.game.stack:pop() if self.onCancel then self.onCancel() end end @@ -240,6 +272,11 @@ function Gen4Pokedex:move(delta) end local before = self.index self.index = index + -- a step that lands: the list's (ov21_021D5AEC.c:1715/1732/1745), the + -- wheel's (ov21_021D76B0.c:791) and the entry page's (ov21_021E1924.c:1055). + -- The list's LEFT/RIGHT page is five steps on the cartridge, a DENSI06 + -- each; here it is one jump and one effect. + if index ~= before then se(self, "SEQ_SE_DP_DENSI06") end if not self:tabAvailable(self.tab) then self.tab = 1 end if self.page == 'list' and index ~= before then -- a one-entry step slides the plates; anything else only fades the @@ -295,11 +332,41 @@ end function Gen4Pokedex:setTab(tab) if not self:tabAvailable(tab) then return false end - if tab~=self.tab then self:stopCry();self.scroll=0 end + -- ov21_021E29DC.c:1118-1155 / 1414-1447: a tab that changes, DENSI06 + if tab~=self.tab then self:stopCry();self.scroll=0;se(self,"SEQ_SE_DP_DENSI06") end self.tab=tab return true end +-- the size page's A / touch (ov21_021E68F4.c:467/474/732) +function Gen4Pokedex:toggleSize() + self.sizeWeight=not self.sizeWeight + se(self,"SEQ_SE_DP_DECIDE") +end + +-- the form page: the box swapping to another form (formmain.c:347) +function Gen4Pokedex:nextForm() + local before=self.form or 0 + self.form=(before+1)%#self:forms() + if self.form~=before then se(self,"SEQ_SE_DP_DENSI06") end +end + +-- A search button (ov21_021D94BC.c:3089-3395): DECIDE when it changes +-- something -- a field, a choice, the type page -- and never for one already +-- chosen. CANCEL and SEARCH play theirs in backToList / applySearch. +function Gen4Pokedex:searchActivate(b) + if not b then return end + local s=self.searchSelection or {} + local before={self.searchField,self.searchTypePage,s[1],s[2],s[3],s[4],s[5]} + Search.activate(self,b) + if b.action=='cancel' or b.action=='apply' then return end + s=self.searchSelection or {} + local after={self.searchField,self.searchTypePage,s[1],s[2],s[3],s[4],s[5]} + for i=1,7 do + if before[i]~=after[i] then se(self,"SEQ_SE_DP_DECIDE");return end + end +end + function Gen4Pokedex:bottomVisible() if SecondScreen.stowed(self.game) then return false end return SecondScreen.mode(self.game)~='swap' or SecondScreen.raised(self.game) @@ -307,17 +374,25 @@ end function Gen4Pokedex:backToList() self.searchPointer,self.searchPressed=nil,nil + -- the entry page's back (ov21_021E29DC.c:1112/1358/1459) and search's + -- CANCEL (ov21_021D94BC.c:3089), then the list coming back up + se(self,"SEQ_SE_DP_DECIDE") + if self.page~='list' then self:listTransition() end self:stopCry();self.wheelPointer=nil;self.page,self.scroll='list',0 end function Gen4Pokedex:openEntry() if not self:status(self:species()) then return false end + -- A on an entry (ov21_021D5AEC.c:563), then the list's exit + se(self,"SEQ_SE_DP_DECIDE");self:listTransition() self.wheelPointer=nil;self.page='entry';self.tab=1;self.form=0;self:playCry() return true end function Gen4Pokedex:openSearch() if self.filtered then return false end + -- X (ov21_021D747C, ov21_021D5AEC.c:1647), then the list's exit + se(self,"SEQ_SE_DP_DECIDE");self:listTransition() self:stopCry();self.wheelPointer=nil self.searchSelection=Search.defaults();self.searchField=1;self.searchError=nil;self.page='search' self.searchTypePage,self.searchTypeSlot,self.searchCursor=0,3,nil @@ -326,7 +401,9 @@ end function Gen4Pokedex:changeSearch(delta) local field=self.searchField + local was=self.searchSelection[field] self.searchSelection[field]=(self.searchSelection[field]-1+delta)%#Search.CHOICES[field]+1 + if self.searchSelection[field]~=was then se(self,"SEQ_SE_DP_DECIDE") end if field==3 or field==4 then local other=field==3 and 4 or 3 if self.searchSelection[field]~=1 and self.searchSelection[field]==self.searchSelection[other] then @@ -337,11 +414,16 @@ function Gen4Pokedex:changeSearch(delta) end function Gen4Pokedex:applySearch() + -- SEARCH (ov21_021D94BC.c:3119), and the search running + -- (pokedex_search.c:377) -- 32 frames later on the cartridge, at once here + se(self,"SEQ_SE_DP_DECIDE") + se(self,"SEQ_SE_DP_Z_SEARCH") local entries,why=Search.results(self,self.searchSelection) if not entries or #entries==0 then self.searchError=why=='missing data' and 'REIMPORT ROM FOR SEARCH DATA' or 'NONE FOUND' return false end + self:listTransition() self:stopCry();self.entries=entries;self.filtered=true self.page='list';self.index,self.top,self.scroll=1,1,0 return true @@ -349,6 +431,8 @@ end function Gen4Pokedex:cancelResults() if not self.filtered then self:close();return end + -- B on search results (ov21_021D5AEC.c:554) + se(self,"SEQ_SE_DP_DECIDE") local species=self:species() self.filtered=nil;self.entries=self:listing();self.index,self.top=1,1 for i,id in ipairs(self.entries) do if id==species then self.index=i;break end end @@ -360,6 +444,8 @@ function Gen4Pokedex:toggleDex() local orders=self.art and self.art.orders if not dex.national or not (orders and orders.sinnoh and orders.national) then return false end if self.filtered then return false end + -- SELECT (ov21_021D74B0, ov21_021D5AEC.c:1672) + se(self,"SEQ_SE_DP_DECIDE") self:stopCry() self.national=not self.national self.entries=self:listing() @@ -369,6 +455,7 @@ end function Gen4Pokedex:update(dt) local frames=(type(dt)=='number' and dt>0) and math.min(dt*60,4) or 1 + if self.listSePending then self.listSePending=nil;se(self,"SEQ_SE_DP_BUTTON01") end if self.page=='list' then self:updateListAnim(frames) else self.listAnim=nil end if self.page=='entry' and self.tab==3 and self.cryRunning and self.cry and self.cry.isPlaying then local ok,playing=pcall(self.cry.isPlaying,self.cry) @@ -388,7 +475,7 @@ function Gen4Pokedex:update(dt) for _,dir in ipairs({'up','down','left','right'}) do if input:wasPressed(dir) then self.searchCursor=Search.navigate(buttons,self.searchCursor,dir);return end end - if input:wasPressed('a') then Search.activate(self,buttons[self.searchCursor]) + if input:wasPressed('a') then self:searchActivate(buttons[self.searchCursor]) elseif input:wasPressed('start') then self:applySearch() elseif input:wasPressed('b') then self:backToList() end return @@ -404,8 +491,8 @@ function Gen4Pokedex:update(dt) elseif input:wasPressed("a") then if self.tab == 3 then self:pressCryPlay() elseif self.tab == 1 then self:playCry() - elseif self.tab == 4 then self.sizeWeight=not self.sizeWeight - elseif self.tab == 5 then self.form = ((self.form or 0) + 1) % #self:forms() end + elseif self.tab == 4 then self:toggleSize() + elseif self.tab == 5 then self:nextForm() end elseif input:wasPressed("b") or input:wasPressed("start") then self:backToList() end return end @@ -449,9 +536,12 @@ function Gen4Pokedex:touchpressed(id,px,py) if self.tab==3 then if self.cryPointer then return true end if x>=156 and x<204 and y>=107 and y<155 then self:pressCryPlay();self.cryPointer=id - elseif x>=214 and x<246 and y>=150 and y<182 then self.cryLoop=not self.cryLoop;self.cryPointer=id end - elseif self.tab==4 and y>=128 then self.sizeWeight=not self.sizeWeight - elseif self.tab==5 and y>=128 then self.form=((self.form or 0)+1)%#self:forms() end + elseif x>=214 and x<246 and y>=150 and y<182 then + -- LOOP (crysub.c:667) + self.cryLoop=not self.cryLoop;self.cryPointer=id;se(self,"SEQ_SE_DP_DECIDE") + end + elseif self.tab==4 and y>=128 then self:toggleSize() + elseif self.tab==5 and y>=128 then self:nextForm() end return true end -- A secondary-panel event must never be reinterpreted as a top-screen tap. @@ -465,8 +555,8 @@ function Gen4Pokedex:touchpressed(id,px,py) if y >= 176 then local tab = math.min(5, math.floor(x / (W / 5)) + 1) self:setTab(tab) - elseif self.tab == 5 and y >= 128 then self.form = ((self.form or 0) + 1) % #self:forms() - elseif self.tab == 4 and y >= 128 then self.sizeWeight=not self.sizeWeight + elseif self.tab == 5 and y >= 128 then self:nextForm() + elseif self.tab == 4 and y >= 128 then self:toggleSize() elseif self.tab == 3 or x < 96 then self:playCry() end elseif math.abs(x-LIST_PREVIEW.x)<32 and math.abs(y-LIST_PREVIEW.y)<32 then -- the preview is the selected entry @@ -487,7 +577,7 @@ function Gen4Pokedex:searchTouch(x,y,companion) if companion~=false then for i,b in ipairs(Search.buttons(self)) do if x>=b.x-b.w/2 and x=b.y-b.h/2 and y= 224 and y >= 32 and y < 160 then self.held = nil; self:cycle(y < 96 and -1 or 1); return true end + if x >= 224 and y >= 32 and y < 160 then self.held = nil; self:bezel(y < 96 and -1 or 1); return true end if x < FACE.x or x >= FACE.x + FACE.w or y < FACE.y or y >= FACE.y + FACE.h then return true end + -- POKETCH_SYSTEM_MAIN_BUTTON_SCREEN: a touch while the app is changing beeps + if self.shutter then se(self, "SEQ_SE_DP_BEEP") end local name = (self:app() or {}).name local s = self:store() if name == 'Calculator' then for i, r in ipairs(CALC_RECTS) do if inRect(x, y, r[1] * 8, r[2] * 8, (r[1] + r[3]) * 8, (r[2] + r[4]) * 8) then self.calcPressed = i + appSe(self, "SEQ_SE_DP_POKETCH_003") -- calculator Task_PressButton self:calculate(CALC_KEYS[i]) break end end elseif name == 'Memo Pad' and x >= 180 and x < 204 and y >= 24 and y < 88 then + -- memo_pad ButtonCallback: only a change of tool runs CHANGE_TOOL + if not s.memoErasing then appSe(self, "SEQ_SE_DP_POKETCH_003") end s.memoErasing = true elseif name == 'Memo Pad' and x >= 180 and x < 204 and y >= 104 and y < 168 then + if s.memoErasing then appSe(self, "SEQ_SE_DP_POKETCH_003") end s.memoErasing = false elseif name == 'Memo Pad' or name == 'Dot Artist' then self:doodle(x, y) @@ -443,9 +473,15 @@ function Gen4Poketch:touchpressed(id, px, py) end end elseif name == 'Pedometer' then - if x >= 80 and x < 148 and y >= 104 and y < 152 then s.steps = 0; self.buttonDown = true end + if x >= 80 and x < 148 and y >= 104 and y < 152 then + s.steps = 0; self.buttonDown = true + appSe(self, "SEQ_SE_DP_POKETCH_010") -- Task_UpdateButtonSprite, pressed + end elseif name=='Counter' then - if x>=80 and x<148 and y>=104 and y<152 then s.counter = ((s.counter or 0) + 1) % 10000; self.buttonDown = true end + if x>=80 and x<148 and y>=104 and y<152 then + s.counter = ((s.counter or 0) + 1) % 10000; self.buttonDown = true + appSe(self, "SEQ_SE_DP_POKETCH_010") -- Task_UpdateButtonSprite, pressed + end elseif name == 'Stopwatch' then -- stopwatch/main.c: one circular button, centre (112, 112), radius 39 if (x - 112) ^ 2 + (y - 112) ^ 2 < 39 * 39 then self:stopwatchPress() end @@ -484,7 +520,13 @@ function Gen4Poketch:touchpressed(id, px, py) local mon=index and (name~='Pokémon History' and (self.game.save.party or {}) or (s.history or {}))[index] if mon and not mon.isEgg then require('src.core.Sound').playCry(self.game.data,mon.species) + elseif name == 'Pokémon List' and not mon then + -- party_status State_UpdateApp: a tap off the icons refreshes the list + appSe(self, "SEQ_SE_DP_POKETCH_010") end + elseif name == 'Day-Care Checker' then + -- daycare_checker Task_ReloadDaycareState: every tap reloads it + appSe(self, "SEQ_SE_DP_DENSI12") elseif name == 'Digital Watch' or name == 'Analog Watch' then -- digital_watch/main.c ToggleBacklight: the light is on while the -- screen is held @@ -548,7 +590,10 @@ end -- that theme -- `applyLCDPalette` reads it. function Gen4Poketch:colorTouch(x, y) if y >= 136 and y < 160 and x >= 48 and x < 184 then - self:store().color = math.min(7, math.floor((x - 48) / 16)) + local color = math.min(7, math.floor((x - 48) / 16)) + -- CheckColorChanged -> Task_UpdateColor + if color ~= (self:store().color or 0) then appSe(self, "SEQ_SE_DP_POKETCH_003") end + self:store().color = color end end @@ -736,6 +781,7 @@ function Gen4Poketch:updateCoin(dt) a.y=a.y+a.speed; a.speed=a.speed+0.6875 if a.speed>0 and a.y>=144 then a.speed=-a.speed*0.56; a.y=144 + appSe(self, "SEQ_SE_DP_DENSI10") -- every bounce, the last one too if a.speed>=-2 then self.coinAnimation=nil; break end end end @@ -833,6 +879,7 @@ function Gen4Poketch:stopwatchPress() st.wasRunning = s.stopwatchRunning s.stopwatchRunning = false st.sequence, st.t, st.holding, st.held = 1, 0, true, 0 + appSe(self, "SEQ_SE_DP_POKETCH_010") -- Task_HandleTimerTransition end function Gen4Poketch:stopwatchRelease() @@ -855,11 +902,18 @@ function Gen4Poketch:updateStopwatch(tick30) end if st.sequence == 3 or st.sequence == 4 then st.blinkT = (st.blinkT or 0) + 1 - if st.blinkT > (st.sequence == 3 and 6 or 3) then st.blinkT = 0; st.blink = ((st.blink or 0) + 1) % 3 end + if st.blinkT > (st.sequence == 3 and 6 or 3) then + st.blinkT = 0; st.blink = ((st.blink or 0) + 1) % 3 + -- Task_HandleBegin/SpeedUpButtonBlinking: a tick with every blink + appSe(self, "SEQ_SE_DP_POKETCH_010") + end end if st.sequence == 4 then st.t = st.t + 1 - if st.t >= 90 then st.sequence, st.t, st.blinkT, st.blink = 5, 0, 0, 0 end + if st.t >= 90 then + st.sequence, st.t, st.blinkT, st.blink = 5, 0, 0, 0 + appSe(self, "SEQ_SE_DP_POKETCH_006") -- Task_BeginExplosion + end elseif st.sequence == 5 then st.t = st.t + 1 st.blinkT = (st.blinkT or 0) + 1 @@ -943,6 +997,12 @@ end function Gen4Poketch:timerButton(which) local s, mode = self:store(), self:timerMode() + -- kitchen_timer/main.c: a button that does something in this state clicks + -- (POKETCH_003; START clicks in edit even with no time set), one that does + -- nothing beeps; RESET always clicks + local live = (which == 1 and (mode == 'edit' or mode == 'paused' or mode == 'silenced')) + or (which == 2 and (mode == 'running' or mode == 'sounding')) or which == 3 + if live then appSe(self, "SEQ_SE_DP_POKETCH_003") else appSe(self, "SEQ_SE_DP_BEEP") end if which == 1 then -- START if mode == 'edit' then local total = s.timerDuration or 0 @@ -1031,10 +1091,18 @@ function Gen4Poketch:alarmRinging() return s.alarmEnabled and now.hour == (s.alarmHour or 0) and now.min == (s.alarmMinute or 0) end +-- alarm_clock/main.c: flipping the switch clicks (Task_ShowCurrentTime / +-- Task_EnterEditMode play POKETCH_003); pressing the half already down beeps. function Gen4Poketch:alarmTouch(x, y) local s = self:store() - if inRect(x, y, 176, 72, 208, 104) then s.alarmEnabled = true; return end - if inRect(x, y, 176, 104, 208, 136) then s.alarmEnabled = false; return end + if inRect(x, y, 176, 72, 208, 104) then + appSe(self, s.alarmEnabled and "SEQ_SE_DP_BEEP" or "SEQ_SE_DP_POKETCH_003") + s.alarmEnabled = true; return + end + if inRect(x, y, 176, 104, 208, 136) then + appSe(self, s.alarmEnabled and "SEQ_SE_DP_POKETCH_003" or "SEQ_SE_DP_BEEP") + s.alarmEnabled = false; return + end if s.alarmEnabled then return end if inRect(x, y, 64, 112, 80, 128) then s.alarmHour = ((s.alarmHour or 0) + 1) % 24 elseif inRect(x, y, 64, 160, 80, 176) then s.alarmHour = ((s.alarmHour or 0) - 1) % 24 @@ -1276,9 +1344,9 @@ Gen4Poketch.lumShader = lumShader function Gen4Poketch:iconImage(mon) local data = self.game.data local icons = data.icons or {} - local species = mon.isEgg and 'EGG' or mon.species - local entry = (icons.bySpecies or {})[species] or ((data.pokemon or {})[mon.species] or {}).icon - if mon.isEgg and not entry then entry = (icons.bySpecies or {})[mon.species] end + -- an egg's icon follows the species table: 494 the egg, 495 Manaphy's + local species = mon.isEgg and (mon.species == 490 and 495 or 494) or mon.species + local entry = (icons.bySpecies or {})[species] or (not mon.isEgg and ((data.pokemon or {})[mon.species] or {}).icon) local path = type(entry) == 'table' and entry.image or entry if type(path) ~= 'string' then return nil end if self.cache[path] == nil then @@ -1403,6 +1471,8 @@ function Gen4Poketch:updateFriendship() elseif touched then fc.tapOnMon, fc.tap, fc.tapT = true, 'first', 0 else fc.tap = 'jump' + -- each icon's jump reset plays SEQ_SE_DP_POKETCH_010, all on this tick + if #fc.mons > 0 then appSe(self, "SEQ_SE_DP_POKETCH_010") end for _, m in ipairs(fc.mons) do m.action, m.jump, m.jumpPhase, m.cooldown = 'jump', 0, 0, 0 end fc.jumping = #fc.mons end @@ -1582,7 +1652,7 @@ function Gen4Poketch:drawDaycare() local seq = (gender == 'male' or gender == 0) and 10 or (gender == 'female' or gender == 1) and 11 or 12 self:sprite('dcg' .. i, 'daycare_checker', seq, at[5], 40) end - if egg then self:drawMonIcon({ species = 'EGG', isEgg = true }, 112, 136, { seq = 4, id = 'dcegg' }) end + if egg then self:drawMonIcon({ species = 0, isEgg = true }, 112, 136, { seq = 4, id = 'dcegg' }) end end -- ----------------------------------------------------- Pokemon history -- @@ -1610,6 +1680,7 @@ end -- and by 15 -- the importer bakes that in. function Gen4Poketch:startDowsing(x,y) self.dowsingResult=require('src.pokemon.Gen4PoketchState').dowsing(self.game,x,y) + appSe(self, "SEQ_SE_DP_POKETCH_009") -- dowsing_machine State_UpdateApp self.dowsingStart = self.ticks or 0 self.spriteClock = self.spriteClock or {} self.spriteClock.radar, self.spriteClock.items = nil, nil @@ -1665,6 +1736,13 @@ end function Gen4Poketch:rouletteButton(which) local r, s = self:rouletteState(), self:store() + -- roulette/main.c: idle, START and CLEAR click and STOP beeps; spinning, + -- STOP clicks and the others beep; while it runs down nothing answers + if r.phase == 'idle' then + appSe(self, which == 2 and "SEQ_SE_DP_BEEP" or "SEQ_SE_DP_POKETCH_003") + elseif r.phase == 'spin' or r.phase == 'spinup' then + appSe(self, which == 2 and "SEQ_SE_DP_POKETCH_003" or "SEQ_SE_DP_BEEP") + end if which == 1 and r.phase == 'idle' then r.phase, r.speed = 'spinup', 336; r.angle = r.angle + 336 elseif which == 2 and (r.phase == 'spin' or r.phase == 'spinup') then @@ -1717,7 +1795,10 @@ function Gen4Poketch:updateRoulette() r.angle = r.angle + r.speed elseif r.phase == 'stopping' then if r.speed > 80 then r.speed = r.speed - 80; r.angle = r.angle + r.speed - else r.speed, r.phase = 0, 'idle' end + else + r.speed, r.phase = 0, 'idle' + appSe(self, "SEQ_SE_DP_POKETCH_011") -- Task_StopSpinner + end end r.angle = r.angle % 65536 self:store().roulette = r.angle * 360 / 65536 @@ -1735,7 +1816,7 @@ end function Gen4Poketch:drawRoulette() local r = self:rouletteState() - self:drawCell('roulette', 0, 96, 96, { rot = -r.angle * 2 * math.pi / 65536 }) + self:drawCell('roulette', 0, 96, 96, { rot = r.angle * 2 * math.pi / 65536 }) local spinning = r.phase ~= 'idle' local p = r.pressed self:drawCell('roulette', self:animFrame('roulette', (spinning or p == 1) and 2 or 1, 0).cell, 187, 50) @@ -1769,6 +1850,7 @@ function Gen4Poketch:moveTesterTouch(x, y) s.moveTester[slot] = (s.moveTester[slot] - 1 + delta) % (slot == 3 and 18 or 17) + 1 self.appState = self.appState or {} self.appState.mtPressed = i + appSe(self, "SEQ_SE_DP_POKETCH_003") -- move_tester Task_ButtonPressed return end end @@ -1837,10 +1919,11 @@ end local MATCHUP_LEVEL = { [70] = 0, [50] = 1, [20] = 2, [0] = 3 } local MATCHUP_SCRIPTS = { - [3] = { { 'fwd', 16, 16 }, { 'wait', 16 }, { 'flip' }, { 'back', 16, 16 } }, - [2] = { { 'fwd', 16, 16 } }, - [1] = { { 'fwd', 16, 16 }, { 'fwd', 16, 16 } }, - [0] = { { 'fwd', 16, 16 }, { 'fwd', 16, 16 }, { 'fwd', 16, 16 }, { 'wait', 16 }, + [3] = { { 'fwd', 16, 16 }, { 'wait', 16 }, { 'flip' }, { 'se', "SEQ_SE_DP_POKETCH_013" }, { 'back', 16, 16 } }, + [2] = { { 'fwd', 16, 16 }, { 'se', "SEQ_SE_DP_POKETCH_012" } }, + [1] = { { 'fwd', 16, 16 }, { 'se', "SEQ_SE_DP_POKETCH_012" }, { 'fwd', 16, 16 }, { 'se', "SEQ_SE_DP_POKETCH_012" } }, + [0] = { { 'fwd', 16, 16 }, { 'se', "SEQ_SE_DP_POKETCH_012" }, { 'fwd', 16, 16 }, { 'se', "SEQ_SE_DP_POKETCH_012" }, + { 'fwd', 16, 16 }, { 'se', "SEQ_SE_DP_POKETCH_012" }, { 'wait', 16 }, { 'se', "SEQ_SE_DP_POKETCH_014" }, { 'set', 'left', 7 }, { 'set', 'right', 8 }, { 'set', 'meter', 4 }, { 'wait', 16 } }, } @@ -1849,6 +1932,9 @@ function Gen4Poketch:matchupTouch(x, y) local party, s = self:matchupParty(), self:store() local pair = s.matchup or {1,2}; s.matchup = pair if x >= 92 and x < 132 then + -- matchup_checker State_UpdateApp: CHECK needs two Pokemon, else it beeps + if #party < 2 then appSe(self, "SEQ_SE_DP_BEEP"); return end + appSe(self, "SEQ_SE_DP_POKETCH_010") -- Task_PressButton s.compatibility = require('src.pokemon.Gen4PoketchState').compatibility( self.game.data, party[pair[1]], party[pair[2]]) local level = MATCHUP_LEVEL[s.compatibility] or 3 @@ -1885,6 +1971,7 @@ function Gen4Poketch:updateMatchup() m.state, m.timer, m.target, m.step = 'move', cmd[2], m.offset + dist, dist / cmd[2] elseif cmd[1] == 'wait' then m.state, m.timer = 'wait', cmd[2] elseif cmd[1] == 'flip' then m.left, m.right = 6, 5 + elseif cmd[1] == 'se' then appSe(self, cmd[2]) -- ANIM_COMMAND_PLAY_SOUND elseif cmd[1] == 'set' then m[cmd[2]] = cmd[3] end end end @@ -1992,6 +2079,18 @@ function Gen4Poketch:updateApp() elseif name == 'Friendship Checker' then if tick30 then self:updateFriendship() end elseif name == 'Roulette' then if tick30 then self:updateRoulette() end elseif name == 'Matchup Checker' then if tick30 then self:updateMatchup() end + elseif name == 'Kitchen Timer' then + -- Task_BeatHands: SEQ_SE_DP_DENSI11 with each beat, every 8 ticks + if self:timerMode() == 'sounding' and (self.ticks or 0) % 16 == 0 then appSe(self, "SEQ_SE_DP_DENSI11") end + elseif name == 'Alarm Clock' then + -- State_AlarmBlaring: Task_SoundAlarm clicks as it starts ringing, and + -- Task_ShowCurrentTime as the minute runs out while it is still set + local ringing = self:alarmRinging() and true or false + if self.alarmWasRinging ~= nil and ringing ~= self.alarmWasRinging + and (ringing or self:store().alarmEnabled) then + appSe(self, "SEQ_SE_DP_POKETCH_003") + end + self.alarmWasRinging = ringing end end diff --git a/src/ui/Gen4ShopMenu.lua b/src/ui/Gen4ShopMenu.lua index fa39e24e1..9fc36e8cd 100644 --- a/src/ui/Gen4ShopMenu.lua +++ b/src/ui/Gen4ShopMenu.lua @@ -53,6 +53,18 @@ local LIST_X,LIST_Y,ROW_HEIGHT,VISIBLE_ROWS=96,16,16,7 Shop.layout={iconX=22,iconY=172,listX=LIST_X,listY=LIST_Y,rowHeight=ROW_HEIGHT,visibleRows=VISIBLE_ROWS,descriptionX=40,descriptionY=144} Shop.__index = Shop Shop.isOpaque = false +-- THE COUNTER'S SOUNDS are shop_menu.c's and the list/menu code it calls: +-- SEQ_SE_DP_SELECT (SE_CONFIRM_sseq_3) for every A / B and the BUY / SELL / +-- SEE YA! and YES / NO cursors (menu.c Menu_ProcessInput), SEQ_SE_DP_BAG_004 +-- for the item list's cursor (Shop_MenuCursorCallback) and the quantity dial +-- (Shop_SelectPurchaseMenu), SEQ_SE_DP_REGI for the till on YES +-- (Shop_SelectConfirmPurchase; bag/main.c ResolveSale for a sale). +local function se(self, name, opts) + local data = self.game and self.game.data + local ok, Sound = pcall(require, "src.core.Sound") + if ok and type(Sound) == "table" and Sound.playSe and data then return Sound.playSe(data, name, opts) end +end + function Shop:uiSize() return 256, 192 end function Shop:wantsFillScale() return true end function Shop:sgbPalettes() return {require('src.render.PaletteFX').trueColorZone(0,0,31,23)} end @@ -189,7 +201,11 @@ function Shop:rows() return self.stock end -function Shop:back() +-- `silent`: the Bag closed itself (its own B made the sound) +function Shop:back(silent) + -- MENU_CANCEL / B: Menu_ProcessInput, Shop_SelectBuyMenu, + -- Shop_SelectPurchaseMenu, ProcessItemCountInput_SellCount + if not silent then se(self, "SEQ_SE_DP_SELECT") end if self.mode == 'menu' then return self:close() end if self.mode == 'quantity' or self.mode == 'confirm' then self.mode = self.transaction @@ -207,7 +223,7 @@ function Shop:openBag() local ok = pcall(require('src.ui.Screens').push, self.game, 'BagMenu', { pick = true, onPick = function(id) self:sellItem(id) end, - onCancel = function() self.transaction = nil; self.mode = 'sell'; self:back() end, + onCancel = function() self.transaction = nil; self.mode = 'sell'; self:back(true) end, }) return ok end @@ -230,6 +246,8 @@ end function Shop:choose() if self.mode == 'menu' then + -- Menu_ProcessInput: A + se(self, "SEQ_SE_DP_SELECT") if self.cursor == #self:rows() then return self:close() end if self.cursor == 1 then self.mode = 'buy' @@ -242,9 +260,13 @@ function Shop:choose() if self.mode == 'sell' then return self:openBag() end if self.mode == 'confirm' then if self.yesNo == 2 then return self:back() end + -- Menu_ProcessInputAndHandleExit: A on YES + se(self, "SEQ_SE_DP_SELECT") return self:transact() end if self.mode == 'quantity' then + -- Shop_SelectPurchaseMenu / ProcessItemCountInput_SellCount: A + se(self, "SEQ_SE_DP_SELECT") self.mode, self.yesNo = 'confirm', 1 local def = self:def(self.item) if self.transaction == 'buy' and self.frontier then @@ -273,7 +295,9 @@ function Shop:choose() local price = tonumber(def.price) or 0 self.unit = price self.max = math.min(99, price > 0 and math.floor(self:funds() / price) or 99) + -- not enough money: the line, and no sound (Shop_SelectBuyMenu) if self.max < 1 then self.message = self:poorLine(); return end + se(self, "SEQ_SE_DP_SELECT") self.item, self.transaction, self.qty, self.mode = id, 'buy', 1, 'quantity' if self.frontier then self.message = self:line(33, (def.name or '') .. '? OK.\nHow many would you like?', def.name) @@ -290,9 +314,12 @@ function Shop:transact() self.message = self.goods.fullMessage or 'There is no more room.' elseif self.goods then self.goods.add(self.item, self.qty) + se(self, "SEQ_SE_DP_REGI") self:pay(cost); self.message = 'Thank you!' elseif not Bag.add(save, self.item, self.qty, self.game.data) then self.message = self:line(7, 'Your Bag is full.') else self:pay(cost) + -- the till (Shop_SelectConfirmPurchase, on YES) + se(self, "SEQ_SE_DP_REGI") self.purchases = bump(self.purchases, self.qty) local def = self:def(self.item) or {} local pockets = ((self.game.data.gen4_menus or {}).bag or {}).pockets or {} @@ -307,6 +334,8 @@ function Shop:transact() else if (save.inventory[self.item] or 0) >= self.qty then Bag.remove(save, self.item, self.qty); save.money = math.min(999999, (save.money or 0) + cost) + -- bag/main.c ResolveSale: the till as the money is paid + se(self, "SEQ_SE_DP_REGI") self.sales = bump(self.sales, self.qty) local def = self:def(self.item) or {} self.message = self:bagLine(77, 'Thank you!', def.name or '', tostring(cost)) @@ -317,11 +346,28 @@ function Shop:transact() end function Shop:step(delta) - if self.mode == 'quantity' then self.qty = (self.qty - 1 + delta) % self.max + 1; return end - if self.mode == 'confirm' then self.yesNo = (self.yesNo - 1 + (delta > 0 and 1 or -1)) % 2 + 1; return end + if self.mode == 'quantity' then + -- sub_0208C15C moved the count: SEQ_SE_DP_BAG_004 (Shop_SelectPurchaseMenu) + local old = self.qty + self.qty = (self.qty - 1 + delta) % self.max + 1 + if self.qty ~= old then se(self, "SEQ_SE_DP_BAG_004") end + return + end + if self.mode == 'confirm' then + -- YES / NO: TryMovingCursorAndPlaySound + self.yesNo = (self.yesNo - 1 + (delta > 0 and 1 or -1)) % 2 + 1 + se(self, "SEQ_SE_DP_SELECT") + return + end if self.mode == 'sell' then return end local count = #self:rows() + (self.mode == 'menu' and 0 or 1) + local old = self.cursor self.cursor = (self.cursor - 1 + delta) % math.max(1, count) + 1 + if self.cursor ~= old then + -- BUY / SELL / SEE YA!: Menu_ProcessInput's cursor; the list: + -- Shop_MenuCursorCallback + se(self, self.mode == 'menu' and "SEQ_SE_DP_SELECT" or "SEQ_SE_DP_BAG_004") + end if self.mode ~= 'menu' then self.message = nil end self.scroll = math.max(0, math.min(self.scroll, self.cursor - 1)) if self.cursor > self.scroll + VISIBLE_ROWS then self.scroll = self.cursor - VISIBLE_ROWS end diff --git a/src/ui/Gen4StartMenu.lua b/src/ui/Gen4StartMenu.lua index 468e2f4bc..526da7450 100644 --- a/src/ui/Gen4StartMenu.lua +++ b/src/ui/Gen4StartMenu.lua @@ -72,6 +72,13 @@ local FALLBACK_LAYOUT = { pitch = 24, iconX = 174, cursorX = 204, firstY = 20, } +-- The cartridge's effects: SE_CONFIRM_sseq_3 is the ROM's SEQ_SE_DP_SELECT. +local function se(self, name, opts) + local data = self.game and self.game.data or self.data + local ok, Sound = pcall(require, "src.core.Sound") + if ok and type(Sound) == "table" and Sound.playSe and data then return Sound.playSe(data, name, opts) end +end + function Gen4StartMenu:uiSize() return W, H end function Gen4StartMenu:wantsFillScale() return true end function Gen4StartMenu:wantsEdgeBleed() return false end @@ -119,7 +126,20 @@ function Gen4StartMenu.new(game, opts) } end end + -- FieldSystem.menuCursorPos: the menu opens on the option it was left on self.index = 1 + for i, row in ipairs(self.rows) do + if row.id == game.gen4StartMenuCursor then self.index = i end + end + self.frame, self.swellFrom = 0, nil + -- field_control.c:332 (and StartMenu_OpenFromScript, start_menu.c:329): + -- the field plays SEQ_SE_DP_WIN_OPEN as it opens the menu. Coming back + -- from a row's screen the cartridge's menu task simply resumes, silently. + if game.gen4StartMenuReturning then + game.gen4StartMenuReturning = nil + else + se(self, "SEQ_SE_DP_WIN_OPEN") + end return self end @@ -201,7 +221,12 @@ function Gen4StartMenu:close() end function Gen4StartMenu:reopen() - return function() Screens.push(self.game, "StartMenu") end + return function() + self.game.gen4StartMenuReturning = true + Screens.push(self.game, "StartMenu") + -- if the push landed on another version's menu, do not leave the mark + self.game.gen4StartMenuReturning = nil + end end function Gen4StartMenu:select() @@ -274,19 +299,81 @@ function Gen4StartMenu:update() if not input then return end local count = #self.rows if count == 0 then return self:close() end + self.frame = (self.frame or 0) + 1 + local before = self.index + -- StartMenu_Select -> Menu_ProcessInputWithSound(menu, SEQ_SE_DP_SELECT78) + -- (start_menu.c:690, menu.c:120-156): A and the cancel keys play + -- SE_CONFIRM_sseq_3, a cursor move that lands plays SELECT78 if input:wasPressed("up") then self.index = (self.index - 2) % count + 1 elseif input:wasPressed("down") then self.index = self.index % count + 1 elseif input:wasPressed("a") then + se(self, "SEQ_SE_DP_SELECT") self:select() elseif input:wasPressed("b") or input:wasPressed("start") then + se(self, "SEQ_SE_DP_SELECT") self:close() end + if self.index ~= before then + se(self, "SEQ_SE_DP_SELECT78") + -- StartMenu_ChangeActiveMenuIcon: the new row's icon swells, then wiggles + self.swellFrom = self.frame + local row = self.rows[self.index] + self.game.gen4StartMenuCursor = row and row.id + end end -- ------------------------------------------------------------------- draw -- +-- THE ICONS (Gen4StartMenuArt): animation icon * 3 + 0 (still, PALETTE_ +-- GRAYSCALE) on every row but the cursor's, whose icon runs 1 (the swell, +-- SRT 1 -> 1.3 -> 1) once it arrives and then 2 (the wiggle, +-10 degrees, +-- looping) in PALETTE_COLORED. The field's sprite clock is 30 Hz, so each +-- NANR frame is two of the port's. +function Gen4StartMenu:iconFrame(row, selected) + local data = self.game.data or {} + local anims = ((data.gen4_start_menu_ink or {}).anims) or {} + local art = data.gen4_start_menu_art or {} + local seq, t = row.icon * 3, 0 + if selected then + local since = (self.frame or 0) - (self.swellFrom or -1000) + local swell = anims[row.icon * 3 + 2] + local swellLen = 0 + for _, f in ipairs((swell or {}).frames or {}) do swellLen = swellLen + (f.duration or 1) * 2 end + if self.swellFrom and since < swellLen then seq, t = row.icon * 3 + 1, since + else seq, t = row.icon * 3 + 2, since - swellLen end + end + local a = anims[seq + 1] + local frame = { cell = row.icon } + if a and a.frames and #a.frames > 0 then + local total = 0 + for _, f in ipairs(a.frames) do total = total + (f.duration or 1) * 2 end + local k = math.max(0, math.floor(t)) + if a.mode == 2 or a.mode == 4 then k = k % math.max(1, total) end + frame = a.frames[#a.frames] + for _, f in ipairs(a.frames) do + local d = (f.duration or 1) * 2 + if k < d then frame = f; break end + k = k - d + end + end + local rec = art[("icon_%02d_%d"):format(frame.cell or row.icon, selected and 1 or 0)] + return rec, frame +end + +function Gen4StartMenu:drawIcon(row, selected, x, y) + local rec, frame = self:iconFrame(row, selected) + local image = rec and self:imgAt(rec.path) + if not image then return false end + local g = love.graphics + g.setColor(1, 1, 1, 1) + local rot = (frame.rot or 0) * 2 * math.pi / 65536 + local sx, sy = frame.sx or 1, frame.sy or 1 + g.draw(image, x + (frame.x or 0), y + (frame.y or 0), rot, sx, sy, -(rec.originX or -16), -(rec.originY or -16)) + return true +end + function Gen4StartMenu:drawPanel() local g = love.graphics local L = self.layout @@ -319,7 +406,7 @@ function Gen4StartMenu:drawPanel() end end - local icon = self:icon(row.icon) + local icon = not (row.icon and self:drawIcon(row, selected, L.iconX, y)) and self:icon(row.icon) if icon then local iw, ih = icon:getDimensions() -- Grey when it is not the row you are on, colour when it is: the @@ -331,16 +418,11 @@ function Gen4StartMenu:drawPanel() g.setColor(1, 1, 1, 1) end + -- The words: the list menu over the window at tile (20, 1), drawn by + -- Menu_New(template, 28, 4, ...) -- left-aligned 28 pixels in, 4 down, + -- one row every 24 (16 + lineSpacing 8). local label = row.label or "" - local width = Font.width(label) - -- Right-aligned into the panel, clear of the icon column: the icons are - -- centred at 174 and the panel ends at panelX + panelW, so the words sit in - -- what is left rather than on top of the pictures. - local right = L.panelX + L.panelW - 6 - local x = right - width - local floor = L.iconX + 14 - if x < floor then x = floor end - Font.draw(label, x, y - 6) + Font.draw(label, L.panelX + 28, L.panelY + 4 + (i - 1) * L.pitch) end g.setColor(1, 1, 1, 1) end diff --git a/src/ui/Gen4SubMenuCursor.lua b/src/ui/Gen4SubMenuCursor.lua new file mode 100644 index 000000000..0f97949c0 --- /dev/null +++ b/src/ui/Gen4SubMenuCursor.lua @@ -0,0 +1,144 @@ +-- src/ui/Gen4SubMenuCursor.lua +-- +-- THE BATTLE SUB-MENUS' KEYPAD CURSOR (src/battle_sub_menus/ +-- battle_sub_menu_cursor.c), shared by the battle bag and the battle party +-- list. A grid of GridMenuCursorPositions -- { x1, y1, x2, y2, up, down, +-- left, right } -- walked with the d-pad: +-- +-- * a neighbour written { prev = n } is +-- BATTLE_SUB_MENU_CURSOR_GO_TO_PREVIOUS_POSITION_INDEX_MASK + n: go back +-- to the position the cursor came from, or n if there is none; +-- * a disabled position is skipped by stepping on in the same direction +-- (the `enabled` mask), and the cursor stays put if that loops; +-- * the previous position is remembered only when the new one's opposite +-- neighbour is a { prev } link (CheckShouldStorePreviousPosition). +-- +-- THE CURSOR STARTS HIDDEN (IsCursorVisible): the first d-pad, A or B press +-- only shows it and is otherwise swallowed. A tap hides it again +-- (BattleBagSprites_DisableCursor / BattlePartySprites_DisableCursor). The +-- visible flag is the battle's (`battle.gen4CursorActive`), which is how the +-- cartridge carries `isCursorEnabled` from the battle's own menu into the bag +-- or the party list and back. +-- +-- Positions are 1-based here; the cartridge's are 0-based. + +local Cursor = {} +Cursor.__index = Cursor + +function Cursor.new(holder, positions, start) + local self = setmetatable({}, Cursor) + self.holder = holder or {} + self:set(positions, start) + return self +end + +function Cursor:visible() return self.holder.gen4CursorActive == true end +function Cursor:show() self.holder.gen4CursorActive = true end +function Cursor:hide() self.holder.gen4CursorActive = false end + +function Cursor:set(positions, start, enabled) + self.positions = positions or {} + self.index = start or 1 + self.previous = nil + self.enabled = enabled +end + +function Cursor:isEnabled(i) + if not self.enabled then return true end + return self.enabled(i) and true or false +end + +local DIRS = { up = "up", down = "down", left = "left", right = "right" } +local OPPOSITE = { up = "down", down = "up", left = "right", right = "left" } + +local function target(link) + if type(link) == "table" then return link.prev, true end + return link, false +end + +function Cursor:move(dir) + local here = self.positions[self.index] + if not here then return false end + local n, isPrev = target(here[dir]) + if not n then return false end + if isPrev and self.previous then n = self.previous end + local enabledHere = true + local guard = 0 + while not self:isEnabled(n) do + enabledHere = false + guard = guard + 1 + local p = self.positions[n] + local nxt = p and select(1, target(p[dir])) + if not nxt or nxt == n or nxt == self.index or guard > 16 then n = self.index; break end + n = nxt + end + if n == self.index then return false end + local new = self.positions[n] + local back = new and new[OPPOSITE[dir]] + if type(back) == "table" and enabledHere then + self.previous = self.index + else + self.previous = nil + end + self.index = n + return true +end + +-- One frame of keys. Returns nil, the chosen index (A), or "back" (B). +function Cursor:tick(input) + if not input then return nil end + local any = input:wasPressed("a") or input:wasPressed("b") + for d in pairs(DIRS) do any = any or input:wasPressed(d) end + if not self:visible() then + if any then self:show(); Cursor.sound("cursor", self.holder.data) end + return nil + end + for d in pairs(DIRS) do + if input:wasPressed(d) then + if self:move(d) then Cursor.sound("cursor", self.holder.data) end + return nil + end + end + if input:wasPressed("a") then return self.index end + if input:wasPressed("b") then Cursor.sound("decide", self.holder.data); return "back" end + return nil +end + +-- The four corners at the position's rectangle (drawCursorCorners insets by +-- eight, so the GridMenu rect is widened by eight first). +function Cursor:draw(battle, game, t) + if not self:visible() then return false end + local c = self.positions[self.index] + if not c then return false end + local Gen4Battle = require("src.battle.Gen4Battle") + local i = Gen4Battle.CURSOR_INSET + return Gen4Battle.drawCursorCorners(battle or { game = game, data = game and game.data, + frame = math.floor((t or 0) * 60) }, + { left = c[1] - i, top = c[2] - i, right = c[3] + i, bottom = c[4] + i }) +end + +-- SE_CONFIRM / SEQ_SE_DP_DECIDE, through whatever the sound module offers; +-- silence is the answer when it offers nothing. +function Cursor.sound(which, data) + local ok, Sound = pcall(require, "src.core.Sound") + if not (ok and type(Sound) == "table" and type(Sound.playSe) == "function") then return end + if not data then + local okG, Game = pcall(require, "src.core.Game") + data = okG and type(Game) == "table" and Game.data or nil + end + if not data then return end + local name = which == "cursor" and "SE_CONFIRM_sseq_3" + or which == "heal" and "SEQ_SE_DP_KAIFUKU_sseq" or "SEQ_SE_DP_DECIDE_sseq" + pcall(Sound.playSe, data, name) +end + +-- { top, bottom, left, right } rects (TouchScreenRect, inclusive): the index +-- hit, or nil +function Cursor.hit(rects, x, y) + for i, r in ipairs(rects) do + if y >= r[1] and y <= r[2] and x >= r[3] and x <= r[4] then return i end + end + return nil +end + +return Cursor diff --git a/src/ui/Gen4SummaryMenu.lua b/src/ui/Gen4SummaryMenu.lua index 413d697fd..79b8e7b84 100644 --- a/src/ui/Gen4SummaryMenu.lua +++ b/src/ui/Gen4SummaryMenu.lua @@ -157,6 +157,14 @@ local Q = { Gen4SummaryMenu.CONDITION_QUADS = Q Gen4SummaryMenu.CONDITION_COLOUR = { 8 / 31, 1, 15 / 31, 20 / 31 } +-- THE CARTRIDGE'S SOUND EFFECTS (main.c / subscreen.c Sound_PlayEffect), by +-- their SDAT names: SE_CONFIRM_sseq_3 is SEQ_SE_DP_SELECT. +local function se(self, name, opts) + local data = self.game and self.game.data + local ok, Sound = pcall(require, "src.core.Sound") + if ok and type(Sound) == "table" and Sound.playSe and data then return Sound.playSe(data, name, opts) end +end + function Gen4SummaryMenu:uiSize() return W, H end function Gen4SummaryMenu:wantsFillScale() return true end function Gen4SummaryMenu:wantsEdgeBleed() return false end @@ -338,7 +346,12 @@ function Gen4SummaryMenu:changePage(delta) -- touch button back in its resting face before the page changes self.subLit=nil for at,i in ipairs(pages) do - if self.page==i then self.page=pages[(at-1+delta)%#pages+1];return end + if self.page==i then + self.page=pages[(at-1+delta)%#pages+1] + -- a page that actually changes flips with SEQ_SE_DP_SELECT5 (main.c 1345) + if self.page~=i then se(self,"SEQ_SE_DP_SELECT5") end + return + end end self:ensureVisiblePage() end @@ -358,8 +371,10 @@ function Gen4SummaryMenu:update(dt) local input = self.game.input if not input then return end if self.moveMode then - if input:wasPressed('up') then self.moveIndex=(self.moveIndex-2)%5+1 - elseif input:wasPressed('down') then self.moveIndex=self.moveIndex%5+1 + -- HandleInput_MoveDetails / _MoveSwap: a step that moves the selector + -- clicks SE_CONFIRM (main.c 688/697, 740/749) + if input:wasPressed('up') then self.moveIndex=(self.moveIndex-2)%5+1;se(self,"SEQ_SE_DP_SELECT") + elseif input:wasPressed('down') then self.moveIndex=self.moveIndex%5+1;se(self,"SEQ_SE_DP_SELECT") elseif input:wasPressed('a') then self:selectMove() elseif input:wasPressed('b') then self:backFromMove() end @@ -367,11 +382,20 @@ function Gen4SummaryMenu:update(dt) end if self.ribbonMode then local count=#self:ribbonList() - if input:wasPressed('b') then self.ribbonMode=nil + -- HandleInput_RibbonSelect: A or B puts the info away with + -- SEQ_SE_DP_DECIDE (main.c 829) + if input:wasPressed('b') or input:wasPressed('a') then + se(self,"SEQ_SE_DP_DECIDE") + self.ribbonMode=nil elseif count>0 then local delta=input:wasPressed('right') and 1 or input:wasPressed('left') and -1 or input:wasPressed('up') and -4 or input:wasPressed('down') and 4 - if delta then self.ribbonIndex=((self.ribbonIndex or 1)-1+delta)%count+1 end + if delta then + local before=self.ribbonIndex or 1 + self.ribbonIndex=(before-1+delta)%count+1 + -- ChangeSelectedRibbon clicks only when the cursor moved (main.c 2117) + if self.ribbonIndex~=before then se(self,"SEQ_SE_DP_SELECT") end + end end return end @@ -385,11 +409,16 @@ function Gen4SummaryMenu:update(dt) elseif input:wasPressed('down') then self:changePokemon(1) elseif input:wasPressed('a') and (key=='moves' or key=='contestMoves') then + -- the move info slides out with SEQ_SE_DP_SYU01 (main.c 620/624) + se(self,"SEQ_SE_DP_SYU01") self.moveMode=true;self.moveIndex=1 elseif input:wasPressed('a') and key=='ribbons' then - if #self:ribbonList()>0 then self.ribbonMode=true;self.ribbonIndex=1 end + -- only with a ribbon to show (main.c 627-631) + if #self:ribbonList()>0 then se(self,"SEQ_SE_DP_DECIDE");self.ribbonMode=true;self.ribbonIndex=1 end elseif input:wasPressed("b") or (input:wasPressed('a') and key=='exit') then + -- main.c 608 (B) and 634 (A on EXIT) + se(self,"SEQ_SE_DP_DECIDE") self:close() end end @@ -539,17 +568,28 @@ function Gen4SummaryMenu:drawMemo() end end +-- B: out of a swap with SEQ_SE_DP_DECIDE (main.c 773), out of the details +-- with SEQ_SE_DP_SYU01 (main.c 723) function Gen4SummaryMenu:backFromMove() - if self.swapMove then self.swapMove=nil - else self.moveMode=nil;self.moveIndex=nil end + if self.swapMove then se(self,"SEQ_SE_DP_DECIDE");self.swapMove=nil + else se(self,"SEQ_SE_DP_SYU01");self.moveMode=nil;self.moveIndex=nil end end +-- A in the details (HandleInput_MoveDetails / _MoveSwap): CANCEL slides the +-- info back with SEQ_SE_DP_SYU01 (main.c 706), a move starts a swap with +-- SEQ_SE_DP_DECIDE (main.c 715) unless the moves are locked (silent); in a +-- swap A always says SEQ_SE_DP_DECIDE (main.c 760/766), CANCEL included, +-- and goes back to the details rather than out of them function Gen4SummaryMenu:selectMove() local index=self.moveIndex - if index==5 then self.swapMove=nil;self:backFromMove();return end + if index==5 then + if self.swapMove then se(self,"SEQ_SE_DP_DECIDE");self.swapMove=nil;return end + self:backFromMove();return + end local moves=self.mon.moves or {} if not moves[index] then return end if self.readOnlyMoves then return end + se(self,"SEQ_SE_DP_DECIDE") if self.swapMove then moves[index],moves[self.swapMove]=moves[self.swapMove],moves[index] self.swapMove=nil @@ -628,6 +668,8 @@ function Gen4SummaryMenu:pressSubButton(index) local b=layout and layout.buttons[index] if not b then return false end self.subLit=nil + -- SUMMARY_BUTTON_STATE_INIT_ANIM (subscreen.c 192) + se(self,"SEQ_SE_DP_BUTTON9") self.subPress={index=index,frame=0,key=b.key} self.tapT=0 local egg=Party.isEgg(self.mon) @@ -685,7 +727,10 @@ function Gen4SummaryMenu:touchpressed(id,px,py) local x,y=(px-r.x)/r.scaleX,(py-r.y)/r.scaleY if not self.moveMode and not self.ribbonMode and y>=16 and y<32 then for _,tab in ipairs(self:pageTabs()) do - if x>=tab.x-8 and x=tab.x-8 and x=144 and x<216 and y>=80 and y<104 then self:close() - elseif self.ribbonMode and x<104 then self.ribbonMode=nil + -- these top-screen taps are the port's stand-ins for A / B, so they say + -- what the button they stand in for says + if ribbons[scroll+slot] then + if not self.ribbonMode then se(self,"SEQ_SE_DP_DECIDE") + elseif self.ribbonIndex~=scroll+slot then se(self,"SEQ_SE_DP_SELECT") end + self.ribbonIndex=scroll+slot;self.ribbonMode=true + end + elseif key=='exit' and x>=144 and x<216 and y>=80 and y<104 then se(self,"SEQ_SE_DP_DECIDE");self:close() + elseif self.ribbonMode and x<104 then se(self,"SEQ_SE_DP_DECIDE");self.ribbonMode=nil elseif (key=='moves' or key=='contestMoves') and x>=128 and y>=32 then local index=math.floor((y-32)/32)+1 if index<=5 then - if not self.moveMode then self.moveMode=true;self.moveIndex=index + if not self.moveMode then se(self,"SEQ_SE_DP_SYU01");self.moveMode=true;self.moveIndex=index else self.moveIndex=index;self:selectMove() end end elseif self.moveMode and x<128 then self:backFromMove() diff --git a/src/ui/Gen4TownMap.lua b/src/ui/Gen4TownMap.lua index a67dc98cc..6ddb2cdaf 100644 --- a/src/ui/Gen4TownMap.lua +++ b/src/ui/Gen4TownMap.lua @@ -17,7 +17,27 @@ -- (TOWN_MAP_GRID_X/Y). The cursor keeps to x 1..28, z 6..28 and steps once -- every three ticks while a direction is held (HandleInput / DoZoomedMapMvt). -- --- NOT DRAWN: the bottom screen's zoomed map and signposts. +-- THE BOTTOM SCREEN (graphics.c; `gen4_town_map_art` / `gen4_town_map_ink` +-- from src/import/Gen4TownMapArt.lua), drawn through src/ui/SecondScreen.lua +-- when a second screen is shown: first the zoom-button page (SUB_1 +-- bottom_screen_zoom_button_screen, the button's 6 x 7 at tile (13, 10)); a +-- tap there (anywhere SUB_1 is not colour 1 / 4) presses the button -- the +-- shockwave at (128, 108), states 1, 2, 1, 0 on odd frames -- fades the sub +-- screen out and brings up the ZOOMED MAP: SUB_3 / SUB_2 512 x 512 scrolled +-- to (16 x + 8 - 112, 16 z - 96) clamped to x 8..248, y 80..304, sliding 5, 5 +-- and 6 pixels a tick as the cursor steps (DoZoomedMapMvt), the header strip +-- at tile (10, 0) with bank 615 line 1, and for a named cell its signpost +-- (field_board's frame and picture, window (9, 3) 21 x 4 for MAP / ARROW, +-- (2, 3) 28 x 4 otherwise, the name in the message font) over the area and +-- landmark descriptions in window (1, 8) at the block's own offsets, each +-- gated by tmap_flags.dat. A tap on the button's rectangle (104..152, +-- 80..136) goes back. Item and Fly mode only; the wall map takes no touch. +-- +-- THE LOCATION HISTORY: the save's OverworldMapHistory (six cells, a ring) +-- -- `save.gen4MapHistory`, written by `Gen4TownMap.pushHistory`, which +-- `remember` calls on every outdoor map entry -- shows as one history dot at +-- a time, oldest to newest, 26 ticks each (LoadLocationHistory / +-- Task_UpdateShownLocationHistoryIdx). Optional: no record, no dots. local Assets = require("src.render.Assets") local Font = require("src.render.Font") @@ -88,6 +108,45 @@ function Gen4TownMap.remember(game) if not (def and def.layout == 0 and game.save) then return end local x, z = Gen4TownMap.playerCell(game) game.save.gen4TownMapCell = { x = x, z = z } + local facing = ow.player and ow.player.facing + Gen4TownMap.pushHistory(game.save, x, z, ({ up = 0, down = 1, left = 2, right = 3 })[facing] or 4) +end + +-- OVERWORLD_MAP_HISTORY_LENGTH entries in a ring (overworld_map_history.c): +-- a push only when the cell is not the current one +Gen4TownMap.HISTORY_LENGTH = 6 +function Gen4TownMap.pushHistory(save, x, z, face) + if not save then return end + local h = save.gen4MapHistory + if type(h) ~= "table" then + h = { pointer = 0, items = {} } + save.gen4MapHistory = h + end + if x == h.currentX and z == h.currentZ then return end + h.items[h.pointer + 1] = { x = x, z = z, face = face or 4, set = true } + h.currentX, h.currentZ = x, z + h.pointer = (h.pointer + 1) % Gen4TownMap.HISTORY_LENGTH +end + +-- TownMapContext_Init + LoadLocationHistory: the five entries before the +-- current one, newest first, without the player's own cell, one per cell +function Gen4TownMap.historyEntries(save, px, pz) + local h = save and save.gen4MapHistory + local out = {} + if type(h) ~= "table" or type(h.items) ~= "table" then return out end + local n = Gen4TownMap.HISTORY_LENGTH + local at = ((h.pointer or 0) - 2 + n) % n + for _ = 1, n - 1 do + local e = h.items[at + 1] + if not (e and e.set) then break end + if not (e.x == px and e.z == pz) then + local found + for _, o in ipairs(out) do if o.x == e.x and o.z == e.z then found = o end end + if not found then out[#out + 1] = { x = e.x, z = e.z } end + end + at = (at - 1 + n) % n + end + return out end function Gen4TownMap.new(game, opts) @@ -102,6 +161,11 @@ function Gen4TownMap.new(game, opts) self.px, self.pz = Gen4TownMap.playerCell(game) self.cx, self.cz = self.px, self.pz self.frame, self.moveWait = 0, 0 + -- the bottom screen + self.zoomed = false + self.zx, self.zy = self.cx * 16 + 8 - 112, self.cz * 16 - 96 + self.info = self.mode ~= "wall" and self:blockAt(self.cx, self.cz) or nil + self.history = self.mode ~= "wall" and Gen4TownMap.historyEntries(game.save, self.px, self.pz) or {} return self end @@ -226,8 +290,11 @@ end function Gen4TownMap:update() self.frame = self.frame + 1 + self:stepBottom() local input = self.game.input if not input then return end + -- zoomedInMapTransitionStage > 1: nothing until the sub screen has switched + if self.switching then return end if input:wasPressed("b") then return self:close() end if self.mode == "fly" and input:wasPressed("a") then local header = Gen4TownMap.headerAt(self.game, self.cx, self.cz) @@ -245,18 +312,381 @@ function Gen4TownMap:update() if down("down") and self.cz <= 27 then self.cz = self.cz + 1 moved = true end if down("right") and self.cx <= 27 then self.cx = self.cx + 1 moved = true end if down("left") and self.cx >= 2 then self.cx = self.cx - 1 moved = true end - if moved then self.moveWait = STEP_FRAMES - 1 end + if moved then + self.moveWait = STEP_FRAMES - 1 + -- DoZoomedMapMvt: three ticks of 5 + 1, 5, 5 toward the new cell; a + -- second step in a row takes the signpost down until the pad is let go + local tx, ty = self.cx * 16 + 8 - 112, self.cz * 16 - 96 + if self.mvt or self.held then self.info = nil end + self.mvt = { dx = tx > self.zx and 1 or (tx < self.zx and -1 or 0), + dy = ty > self.zy and 1 or (ty < self.zy and -1 or 0), stage = 3, wait = 0 } + self.held = true + elseif not (down("up") or down("down") or down("left") or down("right")) then + self.held = false + end +end + +-- ------------------------------------------------------- bottom screen -- + +-- one vblank of the bottom screen: the zoomed map's slide, the zoom +-- button's press and the sub-screen fades, the history dot's turn +function Gen4TownMap:stepBottom() + local m = self.mvt + if m then + m.wait = m.wait + 1 + if m.wait >= 2 then -- a 30 Hz tick + m.wait = 0 + local speed = m.stage == 3 and 6 or 5 + self.zx, self.zy = self.zx + m.dx * speed, self.zy + m.dy * speed + m.stage = m.stage - 1 + if m.stage <= 0 then + self.zx, self.zy = self.cx * 16 + 8 - 112, self.cz * 16 - 96 + self.mvt = nil + end + end + end + -- UpdateBottomScreenText once the movement is done and the pad let go + if not self.mvt and not self.held then self.info = self:blockAt(self.cx, self.cz) end + local sw = self.switching + if sw then + sw.k = sw.k + 1 + if sw.to and sw.k == Gen4TownMap.PRESS_FRAMES + Gen4TownMap.FADE_FRAMES then self.zoomed = true end + if not sw.to and sw.k == Gen4TownMap.FADE_FRAMES then self.zoomed = false end + local total = (sw.to and Gen4TownMap.PRESS_FRAMES or 0) + 2 * Gen4TownMap.FADE_FRAMES + if sw.k >= total then self.switching = nil end + end +end +Gen4TownMap.PRESS_FRAMES = 10 -- Task_SwitchBottomScreenToZoomedMap cases 0..2 +Gen4TownMap.FADE_FRAMES = 8 -- StartScreenFade(.., 8, 1) +Gen4TownMap.ZOOM_STATES = { 1, 2, 1, 0 } +Gen4TownMap.ZOOM_RECT = { 104, 80, 152, 136 } -- { 13, 10 } 6 x 7 tiles +Gen4TownMap.HISTORY_TICKS = 26 + +-- the zoom button's state this frame +function Gen4TownMap:zoomButtonState() + local sw = self.switching + if not (sw and sw.to) then return 0 end + -- case 1 redraws on odd frame counts: state[count / 2] + local n = sw.k - 1 + if n < 1 then return 0 end + local last = 0 + for c = 1, math.min(n, 7), 2 do last = Gen4TownMap.ZOOM_STATES[math.floor(c / 2) + 1] or 0 end + return last +end +-- the sub screen's fade to black, 0..1 +function Gen4TownMap:bottomFade() + local sw = self.switching + if not sw then return 0 end + local k = sw.k - (sw.to and Gen4TownMap.PRESS_FRAMES or 0) + if k < 0 then return 0 end + local F = Gen4TownMap.FADE_FRAMES + if k <= F then return k / F end + return math.max(0, 1 - (k - F) / F) +end + +function Gen4TownMap:bottomShown() + local okS, SS = pcall(require, "src.ui.SecondScreen") + if not okS or not self.game then return false end + local mode = SS.mode(self.game) + local combined = SS.combined and SS.combined(self.game) + return ((mode == "display" or mode == "inset") and not SS.stowed(self.game)) or combined or false, SS +end + +-- GetTouchedZone +function Gen4TownMap:zoomHit(x, y) + local ink = self.game.data.gen4_town_map_ink + local runs = ink and ink.zoomHit + if not runs then return true end + y = math.floor(y) + x = math.floor(x) + for _, r in ipairs(runs) do + if r[1] == y and x >= r[2] and x <= r[3] then return true end + end + return false +end +function Gen4TownMap:bottomTouch(x, y) + if self.mode == "wall" then return true end + if self.switching or self.mvt or self.touching then return true end + self.touching = true + if self.zoomed then + local r = Gen4TownMap.ZOOM_RECT + if x >= r[1] and x <= r[3] and y >= r[2] and y <= r[4] then self.switching = { k = 0, to = false } end + elseif self:zoomHit(x, y) then + self.switching = { k = 0, to = true } + end + return true +end +function Gen4TownMap:touchpressed(id, px, py) + local shown, SS = self:bottomShown() + local x, y + if shown then x, y = SS.toLocal(self.game, px, py) end + if not x then return self.game.secondScreenInjecting and true or false end + self.pointer = id + return self:bottomTouch(x, y) +end +function Gen4TownMap:touchreleased(id) + if self.pointer ~= id then return false end + self.pointer, self.touching = nil, false + return true +end + +-- the words of bank 615 (TEXT_BANK_TOWN_MAP) +function Gen4TownMap:townText(n) + local ok, T = pcall(require, "src.import.Gen4Text") + local text = ok and T.resolve(self.game.data, 615, n, self.game) + if type(text) ~= "string" or text == "" then + text = (self.game.data.text or {})[("TEXT_B0615_%05d"):format(n)] + end + return type(text) == "string" and text ~= "" and text or nil +end + +-- PerformTownMapDescriptionsChecks for one block's area (or landmark) line +function Gen4TownMap:descShown(block, which) + local ink = self.game.data.gen4_town_map_ink or {} + local f = (ink.descFlags or {})[(block.index or 0) + 1] + if not f then return true end + local kind, flag + if which == "area" then kind, flag = f.areaType, f.areaFlag else kind, flag = f.landmarkType, f.landmarkFlag end + local flags = (self.game.save or {}).flags + if kind == 1 then return flagOpen(self.game.save, flag) end + if kind == 2 then return type(flags) == "table" and flags[("FLAG_G4_%04X"):format(flag)] == true end + return true +end + +function Gen4TownMap:artImage(key) + local rec = (self.game.data.gen4_town_map_art or {})[key] + local path = type(rec) == "table" and rec.path or rec + if not path then return nil end + if self.cache[path] == nil then + local ok, image = pcall(Assets.image, path) + self.cache[path] = ok and image or false + if self.cache[path] then self.cache[path]:setFilter("nearest", "nearest") end + end + return self.cache[path] or nil, type(rec) == "table" and rec or {} +end + +-- a 512 x 512 layer seen through the 256 x 192 screen at (sx, sy) +local function drawScrolled(self, img, sx, sy) + if not img then return end + local g = love.graphics + local w, h = img:getDimensions() + self.scrollQuads = self.scrollQuads or {} + for _, ox in ipairs({ 0, w }) do + for _, oy in ipairs({ 0, h }) do + g.draw(img, ox - sx % w, oy - sy % h) + end + end +end + +-- ShowHiddenLocation's bottom-screen pieces: { srcX, srcY, destX, destY, w, h } +-- in tiles of the 512 x 512 layers, [3] onto SUB_3, [4] onto SUB_2 +local HIDDEN_BOTTOM = { + [0] = { [3] = { 8, 0, 3, 16, 4, 4 }, [4] = { 8, 4, 3, 16, 4, 4 } }, + [1] = { [3] = { 0, 0, 8, 16, 4, 4 }, [4] = { 0, 4, 8, 16, 4, 4 } }, + [2] = { [4] = { 8, 8, 48, 42, 5, 6 } }, + [3] = { [3] = { 4, 0, 57, 12, 4, 4 }, [4] = { 4, 4, 57, 12, 4, 19 } }, +} +Gen4TownMap.HIDDEN_BOTTOM = HIDDEN_BOTTOM +function Gen4TownMap:drawHiddenBottom(layer, sx, sy) + local img = self:artImage("hidden") + if not img then return end + local ok, H = pcall(require, "src.world.Gen4HiddenPaths") + if not ok then return end + local g = love.graphics + for loc = 0, 3 do + local r = HIDDEN_BOTTOM[loc][layer] + local okU, open = pcall(H.unlocked, self.game.save, loc) + if r and okU and open then + local q = g.newQuad(r[1] * 8, r[2] * 8, r[5] * 8, r[6] * 8, img:getDimensions()) + for _, ox in ipairs({ 0, 512 }) do + for _, oy in ipairs({ 0, 512 }) do + g.draw(img, q, r[3] * 8 + ox - sx % 512, r[4] * 8 + oy - sy % 512) + end + end + end + end +end + +-- Window_DrawSignpost: field_board's frame tiles round a window, the picture +-- for a MAP / ARROW signpost at x - 7 +function Gen4TownMap:drawSignpost(block, name) + local g = love.graphics + local t = block.signpostType or 2 + local strip = self:artImage("sign_frame_" .. t) + local ink = (self.game.data.gen4_town_map_ink or {}).signInk or {} + local si = ink[t] or {} + local boardType = (t == 0 or t == 1) + local wx, wy, ww, wh = boardType and 9 or 2, 3, boardType and 21 or 28, 4 + if strip then + self.signQuads = self.signQuads or {} + local function tile(i, tx, ty, n) + local key = tostring(strip) .. i + if not self.signQuads[key] then self.signQuads[key] = g.newQuad(i * 8, 0, 8, 8, strip:getDimensions()) end + for k = 0, (n or 1) - 1 do g.draw(strip, self.signQuads[key], (tx + k) * 8, ty * 8) end + end + g.setColor(1, 1, 1, 1) + local left = boardType and wx - 9 or wx - 2 + local runFrom = boardType and wx - 7 or wx + local runLen = boardType and ww + 7 or ww + for _, row in ipairs({ { wy - 1, 0 }, { wy + wh, 12 } }) do + tile(row[2], left, row[1]); tile(row[2] + 1, left + 1, row[1]) + tile(row[2] + 2, runFrom, row[1], runLen) + tile(row[2] + 3, wx + ww, row[1]); tile(row[2] + 4, wx + ww + 1, row[1]); tile(row[2] + 5, wx + ww + 2, row[1]) + end + for y = wy, wy + wh - 1 do + tile(6, left, y); tile(7, left + 1, y) + if boardType then tile(8, wx - 1, y) end + tile(9, wx + ww, y); tile(10, wx + ww + 1, y); tile(11, wx + ww + 2, y) + end + end + if boardType then + local pic = self:artImage(("sign_%d_%d"):format(t, block.signpost or 0)) + if pic then g.setColor(1, 1, 1, 1); g.draw(pic, (wx - 7) * 8, wy * 8) end + end + local paper = si.paper or { 255, 255, 255 } + g.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) + g.rectangle("fill", wx * 8, wy * 8, ww * 8, wh * 8) + g.setColor(1, 1, 1, 1) + if name then + local faced = Font.pushFace and Font.pushFace("message") + local tc, sc = si.text or { 82, 82, 90 }, si.shadow or { 165, 165, 173 } + Font.pushStyle({ text = { tc[1] / 255, tc[2] / 255, tc[3] / 255 }, shadow = { sc[1] / 255, sc[2] / 255, sc[3] / 255 } }) + Font.draw(name, wx * 8, wy * 8) + Font.popStyle() + if faced and Font.popFace then Font.popFace() end + end +end + +function Gen4TownMap:drawBottom() + local g = love.graphics + local ink = self.game.data.gen4_town_map_ink or {} + local bd = ink.backdrop or { 41, 90, 66 } + g.setColor(bd[1] / 255, bd[2] / 255, bd[3] / 255, 1) + g.rectangle("fill", 0, 0, W, H) + g.setColor(1, 1, 1, 1) + if not self.zoomed then + local screen = self:artImage("zoom_screen") + if screen then g.draw(screen, 0, 0) end + local btn = self:artImage("zoom_button_" .. self:zoomButtonState()) + if btn then g.draw(btn, 104, 80) end + -- the shockwave, cells 0 / 1 / 2 four frames each, while pressed + local sw = self.switching + if sw and sw.to and sw.k < Gen4TownMap.PRESS_FRAMES then + local cell = math.min(2, math.floor(sw.k / 4)) + local img, rec = self:artImage("shockwave_" .. cell) + if img then g.draw(img, 128 + (rec.originX or -32), 108 + (rec.originY or -32)) end + end + else + -- MoveZoomedInMap's clamp + local sx = math.max(8, math.min(256 - 8, self.zx)) + local sy = math.max(72 + 8, math.min(240 + 72 - 8, self.zy)) + drawScrolled(self, self:artImage("zoom_bg"), sx, sy) + self:drawHiddenBottom(3, sx, sy) + drawScrolled(self, self:artImage("zoom_map"), sx, sy) + self:drawHiddenBottom(4, sx, sy) + local header = self:artImage("header") + if header then g.draw(header, 80, 0) end + local title = self:townText(1) + if title then + local faced = Font.pushFace and Font.pushFace("system") + local tw = Font.width and Font.width(title) or #title * 6 + Font.draw(title, 88 + math.floor((80 - tw) / 2), 0) + if faced and Font.popFace then Font.popFace() end + end + local block = self.info + if block then + local name = self:nameAt(self.cx, self.cz) + self:drawSignpost(block, name) + local si = (ink.signInk or {})[block.signpostType or 2] or {} + local tc, sc = si.text or { 82, 82, 90 }, si.shadow or { 165, 165, 173 } + local full + for _, b in ipairs(ink.blocks or {}) do + if b.x == block.x and b.z == block.z then full = b break end + end + full = full or block + local faced = Font.pushFace and Font.pushFace("system") + Font.pushStyle({ text = { tc[1] / 255, tc[2] / 255, tc[3] / 255 }, shadow = { sc[1] / 255, sc[2] / 255, sc[3] / 255 } }) + for _, which in ipairs({ "area", "landmark" }) do + local n = which == "area" and full.areaDesc or full.landmarkDesc + if n and n ~= 0xFFFF and self:descShown(full, which) then + local text = self:townText(n) + if text then + local x = 8 + (which == "area" and (full.areaX or 0) or (full.landmarkX or 0)) + local y = 64 + (which == "area" and (full.areaY or 0) or (full.landmarkY or 0)) + for line in (text:gsub("[\r\f\v]", "\n") .. "\n"):gmatch("([^\n]*)\n") do + Font.draw(line, x, y); y = y + 16 + end + end + end + end + Font.popStyle() + if faced and Font.popFace then Font.popFace() end + end + end + local fade = self:bottomFade() + if fade > 0 then g.setColor(0, 0, 0, fade); g.rectangle("fill", 0, 0, W, H); g.setColor(1, 1, 1, 1) end +end + +function Gen4TownMap:drawSubscreen() + local shown, SS = self:bottomShown() + if not shown then return end + SS.draw(self.game, function() self:drawBottom() end) end -- ------------------------------------------------------------------ draw -- +-- ShowHiddenLocation: each unlocked hidden location (Gen4HiddenPaths order -- +-- Fullmoon Island, Newmoon Island, Spring Path, Seabreak Path) copies tiles of +-- hidden_locations_top_screen.NSCR into the region BG (layer 1 here, BG3) and +-- the route layer (2, BG2): { srcX, srcY, destX, destY, w, h } in tiles. +local HIDDEN_RECTS = { + [0] = { { 5, 0, 3, 1, 2, 3 }, { 5, 3, 3, 2, 2, 2 } }, + [1] = { { 0, 0, 6, 1, 2, 3 }, { 0, 3, 6, 2, 2, 2 } }, + [2] = { nil, { 4, 5, 22, 13, 3, 3 } }, + [3] = { { 2, 0, 26, 0, 3, 3 }, { 2, 3, 27, 0, 2, 9 } }, +} +Gen4TownMap.HIDDEN_RECTS = HIDDEN_RECTS + +function Gen4TownMap:drawHiddenLocations(layer) + local img = self:img("hidden_locations_top_screen") + if not img then return end + local ok, H = pcall(require, "src.world.Gen4HiddenPaths") + if not ok then return end + local g = love.graphics + self.hiddenQuads = self.hiddenQuads or {} + for loc = 0, 3 do + local r = HIDDEN_RECTS[loc][layer] + local okU, open = pcall(H.unlocked, self.game.save, loc) + if r and okU and open then + local key = loc .. ":" .. layer + local q = self.hiddenQuads[key] + if not q then + q = g.newQuad(r[1] * 8, r[2] * 8, r[5] * 8, r[6] * 8, img:getDimensions()) + self.hiddenQuads[key] = q + end + g.setColor(1, 1, 1, 1) + g.draw(img, q, r[3] * 8, r[4] * 8) + end + end +end + function Gen4TownMap:draw() local g = love.graphics g.clear(0, 0, 0, 1) g.setColor(1, 1, 1, 1) local bg, routes = self:img("top_screen_region_bg"), self:img("top_screen_region_map") - if bg then g.draw(bg, 0, 0) end + if bg then + g.draw(bg, 0, 0) + -- LoadMapGraphics: outside FLY the bar's arrow (between "Fly to where?" + -- and the name) is covered with the bar's own tiles from (0, 21) + if self.mode ~= "fly" then + self.arrowQuad = self.arrowQuad or g.newQuad(0, 168, 16, 24, bg:getDimensions()) + g.draw(bg, self.arrowQuad, 128, 168) + end + end + self:drawHiddenLocations(1) if routes then g.draw(routes, 0, 0) end + self:drawHiddenLocations(2) -- the town blocks local header = Gen4TownMap.headerAt(self.game, self.cx, self.cz) @@ -275,6 +705,16 @@ function Gen4TownMap:draw() end end + -- the location history: one dot at a time, oldest first, a new one every + -- 26 ticks (priority 2, under the player and cursor; palette row 2) + local hist = self.history or {} + if #hist > 0 then + local step = math.floor(self.frame / (2 * Gen4TownMap.HISTORY_TICKS)) + local e = hist[#hist - (step % #hist)] + local dimg, dim = self:sprite("historyDot", 0, 2) + if e and dimg then g.draw(dimg, gridX(e.x) + dim.x, gridY(e.z) + dim.y) end + end + -- the player, then the cursor local girl = ((self.game.save or {}).player or {}).gender girl = girl == "girl" or girl == "female" @@ -297,6 +737,7 @@ function Gen4TownMap:draw() Font.draw(name, wx + math.floor((W - 6 * 8 - tw) / 2), wy) end if faced and Font.popFace then Font.popFace() end + self:drawSubscreen() end return Gen4TownMap diff --git a/src/ui/Gen4TrainerCard.lua b/src/ui/Gen4TrainerCard.lua index 5def46663..b65d101a7 100644 --- a/src/ui/Gen4TrainerCard.lua +++ b/src/ui/Gen4TrainerCard.lua @@ -329,19 +329,31 @@ function Gen4TrainerCard:close() if self.onCancel then self.onCancel() end end -local function sfx(self, name, pitch) +local function sfx(self, name, pitch, handle) local data = self.game and self.game.data if not data then return end local ok, Sound = pcall(require, "src.core.Sound") - if not (ok and type(Sound) == "table" and Sound.play) then return end - local okP, src = pcall(Sound.play, data, name) - if okP and pitch and type(src) == "userdata" and src.setPitch then pcall(src.setPitch, src, pitch) end + if not (ok and type(Sound) == "table" and Sound.playSe) then return end + -- The cartridge's SSEQ effect on its own SE player (NitroAudio); the badge + -- chime's pitch is a playback-rate factor on that player. + local opts = (pitch or handle) and { pitch = pitch, handle = handle } or nil + pcall(Sound.playSe, data, name, opts) +end + +local function chimeSounding(self, handle) + local data = self.game and self.game.data + local ok, Sound = pcall(require, "src.core.Sound") + if not (data and ok and type(Sound) == "table" and Sound.isSePlaying) then return false end + local okP, playing = pcall(Sound.isSePlaying, data, "SEQ_SE_DP_BADGE_C", handle) + return okP and playing end -- ------------------------------------------------------------- the loop -- function Gen4TrainerCard:update() self.t = (self.t or 0) + 1 + -- `TrainerCaseApp_Init`: SEQ_SE_DP_CARD3 as the case opens + if not self.openSfx then self.openSfx = true; sfx(self, "SEQ_SE_DP_CARD3") end local shown = self:subscreenShown() if self.state == "flip" then if self:flipStep() then self.state = "main" end @@ -429,6 +441,7 @@ function Gen4TrainerCard:mainState() if self.page == PAGE_CASE then self.page = PAGE_FRONT end self:startFlip() elseif input == INPUT_B then + sfx(self, "SEQ_SE_DP_SELECT") -- main.c: SE_CONFIRM_sseq_3 on the way out self:close() elseif input == INPUT_SELECT then -- the port's key path to the button @@ -448,7 +461,8 @@ function Gen4TrainerCard:singleScreenState() self.page = self.page % PAGE_CASE + 1 end elseif input:wasPressed("b") or input:wasPressed("start") then - if self.page ~= PAGE_FRONT then self.page = PAGE_FRONT else self:close() end + if self.page ~= PAGE_FRONT then self.page = PAGE_FRONT + else sfx(self, "SEQ_SE_DP_SELECT"); self:close() end end end @@ -679,7 +693,25 @@ end function Gen4TrainerCard:chime(badge) local level = Gen4TrainerCard.polishLevel(self:polish(badge)) local pitch = (CHIME_PITCH[badge] or 0) - 152 * (LEVEL_4_SPARKLES - level) - sfx(self, "SEQ_SE_DP_BADGE_C", 2 ^ (pitch / 768)) + -- badge_chimes.c: four SE players kept most-recent-first. A chime goes to + -- the player already ringing this badge, else a quiet one, else the least + -- recently used; that player moves to the front. + local players = self.chimePlayers + if not players then + players = { { handle = 1 }, { handle = 2 }, { handle = 3 }, { handle = 4 } } + self.chimePlayers = players + end + for _, p in ipairs(players) do + if p.badge and not chimeSounding(self, p.handle) then p.badge = nil end + end + local at + for i, p in ipairs(players) do if p.badge == badge then at = i; break end end + if not at then for i, p in ipairs(players) do if not p.badge then at = i; break end end end + at = at or #players + local p = table.remove(players, at) + p.badge = badge + table.insert(players, 1, p) + sfx(self, "SEQ_SE_DP_BADGE_C", 2 ^ (pitch / 768), p.handle) end -- ------------------------------------------------------------ pointer -- diff --git a/src/ui/Gen5StarterSelect.lua b/src/ui/Gen5StarterSelect.lua new file mode 100644 index 000000000..4a94f1952 --- /dev/null +++ b/src/ui/Gen5StarterSelect.lua @@ -0,0 +1,148 @@ +-- Black and White's gift box: the starter choice in the player's room. +-- +-- The bedroom script (Nuvema's zone, archive member 782) opens it with +-- command 0x153 and reads 0, 1 or 2 back out of the var -- Snivy, +-- Tepig, Oshawott (495 / 498 / 501, the species the script sets for each +-- answer). See Gen5ScriptLink's 0x153. +-- +-- THE WORDS are the cartridge's, story bank 430 (the room's own bank): +-- 12 Professor Juniper's letter, shown when the box opens +-- 16..18 "The Water/Fire/Grass-type Pokemon " +-- 19 "Choose a Pokemon." 20 "Is this the Pokemon you want?" +-- 22 / 23 YES / NO +-- +-- WHAT THIS FILE INVENTS: the layout. The cartridge's app (the box lid, the +-- three balls on their cushion) has its own graphics archive that is not +-- extracted yet; until it is, the three balls are drawn as a row of name +-- plates with the species' picture when the cache has one. The flow -- letter, +-- pick, confirm with YES / NO, no way out without a choice -- is the +-- cartridge's. + +local Font = require("src.render.Font") +local Sprites = require("src.pokemon.Sprites") + +local Screen = {} +Screen.__index = Screen +Screen.isOpaque = true + +local W, H = 256, 192 +local BANK = 430 +-- the script's answers, in its order +Screen.SPECIES = { 495, 498, 501 } +-- which type line names each: Grass 18, Fire 17, Water 16 +local TYPE_LINE = { 18, 17, 16 } + +function Screen:uiSize() return W, H end +function Screen:wantsFillScale() return true end +function Screen:wantsEdgeBleed() return false end + +function Screen.new(game, opts) + opts = opts or {} + local self = setmetatable({}, Screen) + self.game = game + self.species = opts.species or Screen.SPECIES + self.onChoose = opts.onChoose + self.text = (game.data or {}).text or {} + self.index = 1 + self.yes = true + self.state = "letter" -- letter, pick, confirm + return self +end + +function Screen:line(entry) + local s = self.text[("TEXT_B%04d_%05d"):format(BANK, entry)] or "" + local def = (self.game.data.pokemon or {})[self.species[self.index]] + s = s:gsub("{CMD %x+}", ""):gsub("{VAR2}", def and def.name or "") + return (s:gsub("%s+$", "")) +end + +function Screen:update() + local input = self.game.input + if not input then return end + if self.state == "letter" then + if input:wasPressed("a") or input:wasPressed("b") then self.state = "pick" end + elseif self.state == "pick" then + local n = #self.species + if input:wasPressed("right") then self.index = self.index % n + 1 + elseif input:wasPressed("left") then self.index = (self.index - 2) % n + 1 + elseif input:wasPressed("a") then self.state, self.yes = "confirm", true end + else + if input:wasPressed("up") or input:wasPressed("down") then + self.yes = not self.yes + elseif input:wasPressed("b") or (input:wasPressed("a") and not self.yes) then + self.state = "pick" + elseif input:wasPressed("a") then + self.game.stack:pop() + if self.onChoose then self.onChoose(self.index - 1, self.species[self.index]) end + end + end +end + +function Screen:picture(species) + self.pics = self.pics or {} + if self.pics[species] == nil then + local ok, img = pcall(function() + local path = Sprites.path(self.game.data, species, "front") + return path and require("src.render.Assets").image(path) + end) + self.pics[species] = ok and img or false + end + return self.pics[species] or nil +end + +local function drawLines(text, x, y) + for row in (text .. "\n"):gmatch("([^\n]*)\n") do + Font.draw(row, x, y) + y = y + 16 + end +end + +function Screen:draw() + local g = love.graphics + g.setColor(0.93, 0.89, 0.80, 1) + g.rectangle("fill", 0, 0, W, H) + g.setColor(1, 1, 1, 1) + if self.state == "letter" then + Font.drawBox(1, 1, 30, 16) + g.setColor(0, 0, 0, 1) + drawLines(self:line(12), 16, 16) + g.setColor(1, 1, 1, 1) + return + end + -- the three balls + for i, species in ipairs(self.species) do + local cx = 40 + (i - 1) * 88 + if i == self.index then + g.setColor(1, 0.85, 0.3, 1) + g.rectangle("fill", cx - 34, 20, 68, 84) + end + g.setColor(0.85, 0.15, 0.15, 1) + g.circle("fill", cx, 90, 10) + g.setColor(1, 1, 1, 1) + g.arc("fill", cx, 90, 10, 0, math.pi) + local pic = i == self.index and self:picture(species) + if pic then + local pw, ph = pic:getDimensions() + local s = 64 / math.max(pw, ph) + g.draw(pic, cx - pw * s / 2, 22, 0, s, s) + end + end + Font.drawDialogueBox(1, 18, 30, 6) + g.setColor(0, 0, 0, 1) + if self.state == "pick" then + drawLines(self:line(TYPE_LINE[self.index] or 19), 16, 152) + else + drawLines(self:line(20), 16, 152) + end + g.setColor(1, 1, 1, 1) + if self.state == "confirm" then + Font.drawBox(23, 10, 8, 6) + g.setColor(0, 0, 0, 1) + Font.draw(self:line(22), 26 * 8, 12 * 8) + Font.draw(self:line(23), 26 * 8, 14 * 8) + Font.draw(">", 24 * 8 + 4, (self.yes and 12 or 14) * 8) + g.setColor(1, 1, 1, 1) + end +end + +return Screen diff --git a/src/world/Collision.lua b/src/world/Collision.lua index 57985679f..9f58c5b54 100644 --- a/src/world/Collision.lua +++ b/src/world/Collision.lua @@ -65,6 +65,12 @@ local function entityBlocks(e, cx, cy) -- Origin cell is the top-left of the 2x2 (pret big object_event) return cx >= x and cx <= x + 1 and cy >= y and cy <= y + 1 end + -- a wider object (Black / White's invisible blockers: Gen5Events' + -- footprintW x footprintH from its top-left cell) + local fw, fh = e.def and e.def.footprintW, e.def and e.def.footprintH + if fw and fh and (fw > 1 or fh > 1) then + return cx >= x and cx < x + fw and cy >= y and cy < y + fh + end if x == cx and y == cy then return true end if e.targetX == cx and e.targetY == cy then return true end return false @@ -193,7 +199,13 @@ local function verdict(map, entities, mover, dir, tx, ty) if mover.onBike and map.runningBlockedAt and map:runningBlockedAt(tx, ty) then return false, "tile" end - if not map:isWalkableCell(tx, ty) then + -- A MAP'S OWN RULE FIRST, where one is hung on its def (HGSS's + -- GymmickCheckCollision table -- Clair's platforms over the magma): true + -- blocks, false is walkable whatever the tile says, nil asks the tile + local gymSays = map.def and map.def.gen4CollisionHook + and map.def.gen4CollisionHook(map, tx, ty) + if gymSays == true then return false, "tile" end + if gymSays ~= false and not map:isWalkableCell(tx, ty) then if not (mover.surfing and map:isWaterCell(tx, ty)) then return false, "tile" end diff --git a/src/world/Gen4HiddenPaths.lua b/src/world/Gen4HiddenPaths.lua index 84618fb6f..cba86f0d3 100644 --- a/src/world/Gen4HiddenPaths.lua +++ b/src/world/Gen4HiddenPaths.lua @@ -156,6 +156,10 @@ local CHUNK, BLOCKED = 32, 255 local COLLISION = 0x8000 function Gen4HiddenPaths.applyToDef(data, def, save) if not (def and def.generation == 4 and def.layout == OVERWORLD) then return false end + -- Platinum's patches name SINNOH's matrix cells; Black and White's maps + -- carry generation 4 and an overworld matrix 0 of their own, and the + -- patches walled off Route 1's way into Accumula + if def.gen5 then return false end if type(def.blocks) ~= "string" or type(def.behaviorCells) ~= "string" then return false end local key = Gen4HiddenPaths.key(save) if def.gen4HiddenKey == key then return false end diff --git a/src/world/Gen4Roamers.lua b/src/world/Gen4Roamers.lua index 9005992b6..2c2ad833e 100644 --- a/src/world/Gen4Roamers.lua +++ b/src/world/Gen4Roamers.lua @@ -185,10 +185,31 @@ Gen4Roamers.LONG_HOP_ONE_IN = 16 -- your map is met on one wild encounter in two. Gen4Roamers.MEET_ONE_IN = 2 +-- HEARTGOLD / SOULSILVER run the same rules on their own tables +-- (src/world/HgssRoamers: Raikou, Entei, Latias, Latios over 41 Johto and +-- Kanto routes) -- the same odds above, both games, and a long hop kept to +-- the slot's own region. Swapped in on the module, so every function below +-- reads whichever family the loaded cartridge is. +local PLATINUM_TABLES = { + SLOTS = Gen4Roamers.SLOTS, SLOT_COUNT = Gen4Roamers.SLOT_COUNT, + ROUTES = Gen4Roamers.ROUTES, ROUTE_COUNT = Gen4Roamers.ROUTE_COUNT, + NEARBY = Gen4Roamers.NEARBY, +} +local familyNow +function Gen4Roamers.useFamily() + local ok, Bands = pcall(require, "src.import.Gen4ScriptBands") + local f = ok and Bands.family or nil + if f == familyNow then return end + familyNow = f + local t = (f == "hgss") and require("src.world.HgssRoamers") or PLATINUM_TABLES + for k in pairs(PLATINUM_TABLES) do Gen4Roamers[k] = t[k] end +end + -- --------------------------------------------------------------------------- -- the save local function held(save, make) + Gen4Roamers.useFamily() if type(save) ~= "table" then return nil end if make then save.gen4Roamers = save.gen4Roamers or {} end local all = save.gen4Roamers @@ -213,9 +234,25 @@ function Gen4Roamers.mapForHeader(data, header) local index = headerIndex[maps] if not index then index = {} + -- GEN 4 MAPS FIRST: the loaded data can carry another generation's maps + -- with a `header` field of their own (a Johto `R_38` answered for HGSS's + -- header 42, Route 38), so those only fill numbers no Gen 4 map has for id, def in pairs(maps) do - if type(def) == "table" and def.header then index[def.header] = id end + if type(def) == "table" and def.header and def.generation ~= 4 then index[def.header] = id end end + -- ...and of two Gen 4 defs on one header (the loaded data can hold both + -- `R32` and a separate `R_32`), the plain id, so the answer is stable + local gen4 = {} + for id, def in pairs(maps) do + if type(def) == "table" and def.header and def.generation == 4 then + local have = gen4[def.header] + if not have or (tostring(have):find("_") and not tostring(id):find("_")) + or ((tostring(have):find("_") ~= nil) == (tostring(id):find("_") ~= nil) and tostring(id) < tostring(have)) then + gen4[def.header] = id + end + end + end + for header, id in pairs(gen4) do index[header] = id end headerIndex[maps] = index end return index[tonumber(header) or -1] @@ -267,6 +304,7 @@ end -- happens to have it on -- it starts somewhere random that is not the map the -- player just came from. function Gen4Roamers.activate(data, save, slot, rng) + Gen4Roamers.useFamily() slot = math.floor(tonumber(slot) or -1) local def = Gen4Roamers.SLOTS[slot] if not (def and save) then return nil end @@ -334,7 +372,7 @@ end local EVERYWHERE = nil local function everywhere() - if not EVERYWHERE then + if not EVERYWHERE or #EVERYWHERE ~= Gen4Roamers.ROUTE_COUNT then EVERYWHERE = {} for i = 0, Gen4Roamers.ROUTE_COUNT - 1 do EVERYWHERE[#EVERYWHERE + 1] = i end end @@ -354,7 +392,9 @@ function Gen4Roamers.hop(data, save, slot, rng) local it = Gen4Roamers.active(save, slot) or (held(save) or {})[slot] if type(it) ~= "table" then return nil end local hereMap = Gen4Roamers.mapOf(data, tonumber(it.route) or 0) - local choice = pickAllowed(data, everywhere(), rng, + -- ...within the slot's region where it has one (HGSS) + local def = Gen4Roamers.SLOTS[tonumber(slot) or -1] + local choice = pickAllowed(data, (def and def.region) or everywhere(), rng, Gen4Roamers.previousMap(save), hereMap) if not choice then return it.map end it.route, it.map = choice.index, choice.map @@ -470,7 +510,12 @@ function Gen4Roamers.check(data, save, mapId, rng) local here = {} for slot = 0, Gen4Roamers.SLOT_COUNT - 1 do local it = Gen4Roamers.active(save, slot) - if it and it.map == mapId then here[#here + 1] = slot end + -- by HEADER as well as by id: a map can be loaded under two ids + local route = it and Gen4Roamers.ROUTES[tonumber(it.route) or -1] + local def = data and data.maps and data.maps[mapId] + if it and (it.map == mapId or (route and def and def.header == route.header)) then + here[#here + 1] = slot + end end if #here == 0 then return nil end if randInt(rng, Gen4Roamers.MEET_ONE_IN) == 0 then return nil end diff --git a/src/world/HgssApricorns.lua b/src/world/HgssApricorns.lua new file mode 100644 index 000000000..4c8ce1d8f --- /dev/null +++ b/src/world/HgssApricorns.lua @@ -0,0 +1,93 @@ +-- HeartGold / SoulSilver's Apricorns: the trees, the Apricorn Box and Kurt. +-- +-- pokeheartgold, transcribed: +-- +-- * apricorn_tree_sys.c -- 31 trees, each one colour (sTreeApricorns, +-- APRICORN_RED 1 .. APRICORN_BLK 7; the script sees colour - 1); a tree +-- has one Apricorn, shaking it off empties the tree, and every tree that +-- is empty grows one back when a day passes (sub_0202AE74 from the +-- new-day handler). +-- * unk_02031B0C.s -- the box: seven counts capped at 99; Kurt's bytes (+7 +-- quantity, +8 colour), set by taking that many from the box; and +-- ApricornBox_GetKurtBall, colour -> Level, Moon, Lure, Friend, Love, +-- Fast, Heavy Ball (Poke Ball past the end). +-- +-- The state lives on the save (`save.hgssApricorns`). + +local A = {} + +A.RED, A.YLW, A.BLU, A.GRN, A.PNK, A.WHT, A.BLK = 1, 2, 3, 4, 5, 6, 7 + +A.TREES = { + A.BLK, A.PNK, A.YLW, A.GRN, A.BLU, A.GRN, A.GRN, A.PNK, A.BLK, A.PNK, + A.BLK, A.GRN, A.BLU, A.RED, A.BLU, A.BLK, A.WHT, A.GRN, A.PNK, A.GRN, + A.YLW, A.BLK, A.RED, A.GRN, A.GRN, A.YLW, A.BLU, A.WHT, A.RED, A.YLW, + A.WHT, +} + +-- _020F68D0: Level 493, Moon 498, Lure 494, Friend 497, Love 496, Fast 492, +-- Heavy 495 (constants/items.h) +A.BALLS = { 493, 498, 494, 497, 496, 492, 495 } +A.POKE_BALL = 4 + +A.MAX = 99 +A.NAME_BANK, A.NAME_FIRST = 21, 7 -- BufferApricornName: msg_0021_00007 + id + +-- the day number the trees count in (local midnight to midnight) +function A.today(now) + local t = os.date("*t", now or os.time()) + return math.floor(os.time({ year = t.year, month = t.month, day = t.day, hour = 12 }) / 86400) +end + +function A.state(save) + local s = save.hgssApricorns + if not s then + s = { box = { 0, 0, 0, 0, 0, 0, 0 }, kurtColour = 0, kurtQuantity = 0, picked = {} } + save.hgssApricorns = s + end + return s +end + +-- a tree has its Apricorn unless it was shaken today +function A.treeHasFruit(save, idx, now) + local day = A.state(save).picked[tostring(idx)] + return day == nil or day < A.today(now) +end +function A.pick(save, idx, now) + A.state(save).picked[tostring(idx)] = A.today(now) +end +-- ApricornTrees_GetApricorn: the colour, zero-based +function A.treeColour(idx) + local c = A.TREES[(tonumber(idx) or 0) + 1] + return c and (c - 1) or 0 +end + +function A.count(save, colour) return A.state(save).box[(colour or 0) + 1] or 0 end +function A.total(save) + local n = 0 + for i = 1, 7 do n = n + (A.state(save).box[i] or 0) end + return n +end +-- GiveApricornFromTree: refused (false) when it would pass 99 +function A.give(save, colour, n) + local box = A.state(save).box + local i = (colour or 0) + 1 + if not box[i] or box[i] + n > A.MAX then return false end + box[i] = box[i] + n + return true +end + +-- ApricornBox_SetKurtApricorn: colour and quantity, taken from the box +function A.setKurt(save, colour, n) + local s = A.state(save) + s.kurtColour, s.kurtQuantity = colour, n + local i = colour + 1 + if s.box[i] and s.box[i] >= n then s.box[i] = s.box[i] - n end +end +function A.kurtQuantity(save) return A.state(save).kurtQuantity or 0 end +function A.kurtBall(save) + local c = A.state(save).kurtColour or 0 + return A.BALLS[c + 1] or A.POKE_BALL +end + +return A diff --git a/src/world/HgssAzaleaGym.lua b/src/world/HgssAzaleaGym.lua new file mode 100644 index 000000000..babda30ab --- /dev/null +++ b/src/world/HgssAzaleaGym.lua @@ -0,0 +1,137 @@ +-- Bugsy's gym in Azalea Town: the Spinarak carts (HeartGold / SoulSilver). +-- +-- Everything here is pokeheartgold's src/field/gymmick/azalea.c and +-- gymmick_init.c, transcribed: +-- +-- * twelve stations (sSpinarakInitialPositions), four Spinarak standing at +-- four of them -- InitAzaleaGym puts them at 0, 1, 2 and 7 -- and two +-- switches, a bit each; +-- * a route per (station, switch state) (sSpinarakRoutes_From1..12): the +-- node list, and which station the cart ends at; +-- * the ride: the cart (build model 118, drawn 8 units east of its node) +-- runs node to node at 2 units a frame along the normalised segment, the +-- player 8, 8 off it and one tile further down when the ride goes down +-- the room (stations 3, 4, 5, 9, 10, 11 run their route BACKWARDS). +-- +-- What this file leaves out: the switches' own lever animations (props 115, +-- 116, 117, 122), the camera's four-frame shake at the end, and the ride's +-- sound -- the cart and the player move exactly as the cartridge moves them. + +local G = {} + +local function n(x, z) return { x = x, z = z } end + +local R_1to5 = { n(3,31), n(3,29), n(9,29), n(9,27), n(15,28), n(15,25), n(9,25), n(9,24) } +local R_2to6 = { n(9,31), n(9,29), n(3,29), n(3,27), n(9,26), n(9,25), n(15,25), n(15,24) } +local R_3to4 = { n(15,31), n(15,28), n(9,27), n(9,26), n(3,27), n(3,24) } +local R_7to10 = { n(3,16), n(3,12), n(9,13), n(9,10), n(3,10), n(3,9) } +local R_7to11 = { n(3,16), n(3,12), n(9,13), n(9,9) } +local R_8to10 = { n(9,16), n(9,14), n(15,14), n(15,11), n(9,12), n(9,10), n(3,10), n(3,9) } +local R_8to12 = { n(9,16), n(9,14), n(15,14), n(15,9) } +local R_8to11 = { n(9,16), n(9,14), n(15,14), n(15,11), n(9,12), n(9,9) } + +G.STATIONS = { n(3,31), n(9,31), n(15,31), n(3,24), n(9,24), n(15,24), + n(3,16), n(9,16), n(15,16), n(3,9), n(9,9), n(15,9) } + +local function r(route, dest) return { route = route, destination = dest } end +local function same(route, dest) return { r(route, dest), r(route, dest), r(route, dest), r(route, dest) } end + +-- ROUTES[station + 1][switches + 1] (both zero-based on the cartridge) +G.ROUTES = { + same(R_1to5, 4), same(R_2to6, 5), same(R_3to4, 3), same(R_3to4, 2), + same(R_1to5, 0), same(R_2to6, 1), + { false, r(R_7to10, 9), false, r(R_7to11, 10) }, + { r(R_8to10, 9), r(R_8to12, 11), r(R_8to11, 10), r(R_8to12, 11) }, + { false, false, false, false }, + { r(R_8to10, 7), r(R_7to10, 6), false, false }, + { false, false, r(R_8to11, 7), r(R_7to11, 6) }, + { false, r(R_8to12, 7), false, r(R_8to12, 7) }, +} + +G.CART_MODEL = 118 +G.SPEED = 2 -- units a frame, along the segment +G.BACKWARDS = { [3] = true, [4] = true, [5] = true, [9] = true, [10] = true, [11] = true } + +-- InitAzaleaGym +function G.initialState() return { kind = "azalea", spiders = { 0, 1, 2, 7 }, switches = 0 } end + +-- The four carts as runtime props, map world units (Gen4Ground) +function G.props(state, override) + local out = {} + for i, station in ipairs(state.spiders) do + local s = G.STATIONS[station + 1] + local x, z = s.x * 16 + 8, s.z * 16 + if override and override.index == i then x, z = override.x + 8, override.z end + out[i] = { model = G.CART_MODEL, x = x, y = 0, z = z } + end + return out +end + +-- FlipAzaleaGymSwitch +function G.flip(state, which) + local bit = (which == 1) and 2 or 1 + if math.floor(state.switches / bit) % 2 == 1 then state.switches = state.switches - bit + else state.switches = state.switches + bit end +end + +-- ride(state, station) -> a ride, or nil when no cart stands there (the +-- cartridge asserts and does nothing) or the switches give it no route +function G.ride(state, station) + local which + for i, s in ipairs(state.spiders) do if s == station then which = i end end + if not which then return nil end + local route = G.ROUTES[station + 1] and G.ROUTES[station + 1][state.switches + 1] + if not route then return nil end + local backwards = G.BACKWARDS[station] or false + local nodes = {} + if backwards then + for i = #route.route, 1, -1 do nodes[#nodes + 1] = route.route[i] end + else + for i = 1, #route.route do nodes[i] = route.route[i] end + end + return { + spider = which, destination = route.destination, nodes = nodes, at = 1, + x = nodes[1].x * 16, z = nodes[1].z * 16, + -- the player rides a tile below the cart when the ride runs down the room + playerZOffset = backwards and 1 or 0, + stepDir = backwards and "down" or "up", + } +end + +-- step(ride) -> true when the ride has reached its last node. The segment's +-- vector is normalised and doubled once, when the segment starts; each frame +-- an axis that would pass its node stops (AzaleaGymmick_GetMovementDoneParams +-- answering 2), and once both axes are on or past it the cart snaps to the +-- node -- so on a slanted run the slower axis keeps its own speed after the +-- faster one has arrived, as on the cartridge. +local function doneState(sign, cur, delta, target) + if cur + delta == target then return 1 end + if sign then return (cur + delta > target) and 2 or 0 end + return (cur + delta < target) and 2 or 0 +end +function G.step(ride) + local nextNode = ride.nodes[ride.at + 1] + if not nextNode then return true end + local tx, tz = nextNode.x * 16, nextNode.z * 16 + if not ride.vec then + local prev = ride.nodes[ride.at] + local dx, dz = nextNode.x - prev.x, nextNode.z - prev.z + local len = math.sqrt(dx * dx + dz * dz) + ride.vec = { dx / len * G.SPEED, dz / len * G.SPEED, dx >= 0, dz >= 0 } + end + local v = ride.vec + local xd = doneState(v[3], ride.x, v[1], tx) + local zd = doneState(v[4], ride.z, v[2], tz) + if xd == 2 then v[1] = 0 end + if zd == 2 then v[2] = 0 end + if xd ~= 0 and zd ~= 0 then + ride.x, ride.z = tx, tz + ride.at = ride.at + 1 + ride.vec = nil + return ride.at >= #ride.nodes + end + ride.x, ride.z = ride.x + v[1], ride.z + v[2] + return false +end + +return G diff --git a/src/world/HgssBlackthornGym.lua b/src/world/HgssBlackthornGym.lua new file mode 100644 index 000000000..316c780a8 --- /dev/null +++ b/src/world/HgssBlackthornGym.lua @@ -0,0 +1,332 @@ +-- Clair's gym in Blackthorn City: the platforms over the magma +-- (HeartGold / SoulSilver). +-- +-- pokeheartgold src/field/gymmick/blackthorn.c and gymmick_init.c, +-- transcribed. Three platforms (build models 120, 121, 120 at height 48), +-- each a 3 x 6 floor the player can stand on with three buttons on it: the +-- centre ROTATES the platform a quarter turn about the player, the cell +-- either side of it MOVES the platform its own width along its rotated axis. +-- A move steps one tile every 2 frames (8 units a frame) while the tiles in +-- front of its leading wall are magma that nothing solid stands on; meeting +-- anything else it bonks and slides back to where it started. A rotation is +-- 16 frames (a quarter of 16 units of the prop's angle); if a wall is in the +-- way it turns only as far as the extent tables allow and swings back. +-- +-- Points here are tables {x = , z = } in map tiles. + +local G = {} + +G.MODELS = { 120, 121, 120 } +G.HEIGHT = 48 +G.MAGMA = 0x2C -- TILE_BEHAVIOR_MAGMA +G.QUARTER = 0x4000 -- FX_DEG_TO_IDX(90) + +-- InitBlackthornGym +function G.initialState() + return { kind = "blackthorn", x = { 13, 9, 14 }, z = { 75, 58, 32 }, rot = { 0, 1, 0 } } +end + +local MAIN_EXTENTS = { + [0] = { 10, 10, 13, 18, 25, 38, 51, 64, 64, 23, 28, 35, 45, 55 }, + [1] = { 8, 9, 10, 14, 19, 26, 37, 51, 64, 17, 19, 23, 28, 35, 46, 55, + 64, 64, 64, 30, 35, 41, 49, 57 }, +} +local EDGE_EXTENTS = { [0] = { 64, 64, 64, 64, 42 }, [1] = { 64, 20, 30, 46 } } + +local function pt(x, z) return { x = x, z = z } end + +-- BlackthornGymmick_ApplyRotationToPoints, in place +local function rotatePoint(rot, x, z, p) + local dx, dz = p.x - x, p.z - z + if rot == 1 then p.x, p.z = -dz, dx + elseif rot == 2 then p.x, p.z = -dx, -dz + elseif rot == 3 then p.x, p.z = dz, -dx + else return end + p.x, p.z = p.x + x, p.z + z +end + +-- BlackthornPlatform_ApplyRotation (the rotate button and the unused pair +-- stay where they are) +function G.applyRotation(rot, x, z, pf) + local area = pf.shape == 0 and 14 or 24 + rotatePoint(rot, x, z, pf.moveLeftButton) + rotatePoint(rot, x, z, pf.moveRightButton) + for i = 1, pf.height do rotatePoint(rot, x, z, pf.rightWall[i]) end + for i = 1, pf.height do rotatePoint(rot, x, z, pf.leftWall[i]) end + for i = 1, pf.width do rotatePoint(rot, x, z, pf.bottomWall[i]) end + for i = 1, pf.width do rotatePoint(rot, x, z, pf.topWall[i]) end + for i = 1, area do rotatePoint(rot, x, z, pf.rotateCheck[i]) end + for i = 1, pf.width do rotatePoint(rot, x, z, pf.rightEdgeOuter[i]) end + for i = 1, 18 do rotatePoint(rot, x, z, pf.floor[i]) end +end + +-- BlackthornPlatform_Init: index 0-based (1 is the long one) +function G.platform(index, rot, x, z) + local pf = { + rotateButton = pt(x, z), moveLeftButton = pt(x + 1, z), moveRightButton = pt(x - 1, z), + rightWall = {}, leftWall = {}, bottomWall = {}, topWall = {}, rotateCheck = {}, + rightEdgeOuter = {}, floor = {}, + } + if index ~= 1 then + pf.shape, pf.width, pf.height = 0, 5, 7 + for i = 0, 6 do pf.rightWall[i + 1] = pt(x + 3, z - 2 + i) end + for i = 0, 6 do pf.leftWall[i + 1] = pt(x - 3, z - 2 + i) end + for i = 0, 4 do pf.bottomWall[i + 1] = pt(x - 2 + i, z + 5) end + for i = 0, 4 do pf.topWall[i + 1] = pt(x - 2 + i, z - 3) end + for i = 0, 1 do + for j = 0, 6 do pf.rotateCheck[i * 7 + j + 1] = pt(x - 4 - i, z + 4 - j) end + end + else + pf.shape, pf.width, pf.height = 1, 4, 8 + for i = 0, 7 do pf.rightWall[i + 1] = pt(x + 2, z - 2 + i) end + for i = 0, 7 do pf.leftWall[i + 1] = pt(x - 3, z - 2 + i) end + for i = 0, 3 do pf.bottomWall[i + 1] = pt(x - 2 + i, z + 6) end + for i = 0, 3 do pf.topWall[i + 1] = pt(x - 2 + i, z - 3) end + for i = 0, 2 do + for j = 0, 7 do pf.rotateCheck[i * 8 + j + 1] = pt(x - 4 - i, z + 5 - j) end + end + end + for i = 0, pf.width - 1 do pf.rightEdgeOuter[i + 1] = pt(x + 3, z - 2 + i) end + for i = 0, 5 do + for j = 0, 2 do pf.floor[i * 3 + j + 1] = pt(x - 1 + j, z - 1 + i) end + end + pf.rotation = rot + G.applyRotation(rot, x, z, pf) + return pf +end + +function G.platforms(state) + local out = {} + for i = 1, 3 do out[i] = G.platform(i - 1, state.rot[i], state.x[i], state.z[i]) end + return out +end + +-- BlackthornGymmick_GetButtonAction -> platform index (1-based), action +function G.buttonAt(pfs, x, z) + for i, pf in ipairs(pfs) do + if x == pf.rotateButton.x and z == pf.rotateButton.z then return i, "rotate" end + if x == pf.moveLeftButton.x and z == pf.moveLeftButton.z then return i, "right" end + if x == pf.moveRightButton.x and z == pf.moveRightButton.z then return i, "left" end + end + return nil +end + +-- (rotation + 0 or 2) % 4: 0 +x, 1 +z, 2 -x, 3 -z +G.DIR = { [0] = { 1, 0 }, [1] = { 0, 1 }, [2] = { -1, 0 }, [3] = { 0, -1 } } +function G.moveDirection(rotation, action) + return ((action == "right" and 0 or 2) + rotation) % 4 +end + +-- BlackthornGymmick_CheckWallCollision: the first point that is solid or +-- not magma, or nil. `blocked(x, z)` answers the first half, `behaviour(x, +-- z)` the second. +function G.wallHit(points, count, blocked, behaviour) + for i = 1, count do + local p = points[i] + if blocked(p.x, p.z) or behaviour(p.x, p.z) ~= G.MAGMA then return i end + end + return nil +end + +local function maxExtent(points, count, extents, blocked, behaviour) + for i = 1, count do + local p = points[i] + if blocked(p.x, p.z) or behaviour(p.x, p.z) ~= G.MAGMA then + local v = (extents[i] or 64) * 256 + if v < G.QUARTER then return v end + end + end + return G.QUARTER +end + +-- ov04_02255CBC -> "no", "partial" (with the extent, 0..0x4000) or "ok" +function G.rotationCheck(pf, blocked, behaviour) + if G.wallHit(pf.bottomWall, pf.width, blocked, behaviour) then return "no" end + if G.wallHit(pf.topWall, pf.width, blocked, behaviour) then return "no" end + if G.wallHit(pf.leftWall, pf.height, blocked, behaviour) then return "no" end + if G.wallHit(pf.rightWall, pf.width - 1, blocked, behaviour) then return "no" end + local area = pf.shape == 0 and 14 or 24 + local main = maxExtent(pf.rotateCheck, area, MAIN_EXTENTS[pf.shape], blocked, behaviour) + local edge = maxExtent(pf.rightEdgeOuter, pf.width, EDGE_EXTENTS[pf.shape], blocked, behaviour) + local e = math.min(main, edge) + if e < G.QUARTER then return "partial", e end + return "ok" +end + +-- the leading wall a move starts from, copied +function G.leadingWall(pf, action) + local src = action == "right" and pf.rightWall or pf.leftWall + local out = {} + for i = 1, pf.height do out[i] = pt(src[i].x, src[i].z) end + return out +end + +-- GymmickCheckCollision_Blackthorn: false (walkable) on a platform's floor, +-- true (blocked) on bare magma, nil (ask the map) elsewhere +function G.collides(pfs, x, z, behaviour) + for _, pf in ipairs(pfs) do + for i = 1, 18 do + if pf.floor[i].x == x and pf.floor[i].z == z then return false end + end + end + if behaviour(x, z) == G.MAGMA then return true end + return nil +end + +-- ---------------------------------------------------------------- runtime -- +-- The overworld side: `G.attach(ow, save)` (from BlackthornGymInit) hangs +-- four hooks on the map def -- the per-frame driver (`gen4PropFollow`), the +-- button check on each step (`gen4StepHook`), the collision rule +-- (`gen4CollisionHook`) and the floor heights (`gen4HeightEdits`). + +local function mapQueries(ow) + local map = ow.map + local function behaviour(x, z) return map and map.cellBehaviour and map:cellBehaviour(x, z) or 0 end + local function blocked(x, z) return map and map.cellCollision and (map:cellCollision(x, z) or 0) ~= 0 or false end + return blocked, behaviour +end + +local function publish(ow, run) + local def = ow.map and ow.map.def + if not def then return end + local g = run.state + local props, edits = {}, {} + for i = 1, 3 do + local ox, oz, turn = 0, 0, 0 + if run.anim and run.anim.index == i then + ox, oz, turn = run.anim.ox or 0, run.anim.oz or 0, run.anim.turn or 0 + end + props[i] = { model = G.MODELS[i], x = 16 * g.x[i] + 8 + ox, y = G.HEIGHT, z = 16 * g.z[i] + 8 + oz, + rotY = -90 * g.rot[i] - turn, ground = true } + for _, f in ipairs(run.pfs[i].floor) do edits[f.x .. "," .. f.z] = G.HEIGHT end + end + def.gen4RuntimeProps = props + def.gen4HeightEdits = edits +end + +local function finish(ow, run) + local p = ow.player + run.anim = nil + if p then p.inputLocked = run.wasLocked end + run.pfs = G.platforms(run.state) + publish(ow, run) +end + +local STEP_UNITS = 8 -- a frame; a tile is 2 frames + +local function tick(ow, run) + local a = run.anim + if not a then return end + local p = ow.player + local blocked, behaviour = mapQueries(ow) + if a.kind == "move" then + local pf = run.pfs[a.index] + local d = G.DIR[a.dir] + if a.phase == "check" then + if G.wallHit(a.wall, pf.height, blocked, behaviour) then + if a.steps == 0 then finish(ow, run) return end + a.phase, a.timer = "back", 0 + else + a.phase, a.timer = "run", 0 + end + end + if a.phase == "run" then + a.timer = a.timer + 1 + a.ox, a.oz = a.ox + d[1] * STEP_UNITS, a.oz + d[2] * STEP_UNITS + if p then p.px, p.py = p.px + d[1] * STEP_UNITS, p.py + d[2] * STEP_UNITS end + if a.timer >= 2 then + for _, w in ipairs(a.wall) do w.x, w.z = w.x + d[1], w.z + d[2] end + a.steps = a.steps + 1 + if a.steps >= a.distance then + local g = run.state + g.x[a.index], g.z[a.index] = g.x[a.index] + d[1] * a.distance, g.z[a.index] + d[2] * a.distance + if p then + p.cellX, p.cellY = p.cellX + d[1] * a.distance, p.cellY + d[2] * a.distance + p.px, p.py = p.cellX * 16, p.cellY * 16 + end + finish(ow, run) + return + end + a.phase = "check" + end + elseif a.phase == "back" then + a.timer = a.timer + 1 + a.ox, a.oz = a.ox - d[1] * STEP_UNITS, a.oz - d[2] * STEP_UNITS + if p then p.px, p.py = p.px - d[1] * STEP_UNITS, p.py - d[2] * STEP_UNITS end + if a.timer >= 2 then + a.timer = 0 + a.steps = a.steps - 1 + if a.steps <= 0 then + if p then p.px, p.py = p.cellX * 16, p.cellY * 16 end + finish(ow, run) + return + end + end + end + else -- rotate: 90 degrees over 16 frames, or out to the extent and back + local per = 90 / 16 + if a.phase == "run" then + a.turn = a.turn + per + a.timer = a.timer + 1 + if a.timer >= 16 then + local g = run.state + g.rot[a.index] = (g.rot[a.index] + 1) % 4 + finish(ow, run) + return + end + elseif a.phase == "bonk" then + a.turn = math.min(a.turn + per, a.limit) + if a.turn >= a.limit then a.phase = "return" end + elseif a.phase == "return" then + a.turn = a.turn - per + if a.turn <= 0 then finish(ow, run) return end + end + end + publish(ow, run) +end + +-- a button underfoot starts its action (FieldSystem_ProcessStep's first test) +local function step(ow, run, x, z) + if run.anim then return true end + local i, action = G.buttonAt(run.pfs, x, z) + if not i then return false end + local pf = run.pfs[i] + local p = ow.player + run.wasLocked = p and p.inputLocked + local Logger = require("src.core.Logger") + if action == "rotate" then + local verdict, extent = G.rotationCheck(pf, mapQueries(ow)) + Logger.debug("gen4 blackthorn gym: platform %d rotate -- %s%s", i, verdict, + extent and (" to %d / 16384"):format(extent) or "") + if verdict == "no" then return false end + run.anim = { kind = "rotate", index = i, timer = 0, turn = 0, + phase = verdict == "ok" and "run" or "bonk", + limit = extent and (extent / G.QUARTER * 90) or 90 } + else + Logger.debug("gen4 blackthorn gym: platform %d moves %s (direction %d)", i, action, + G.moveDirection(pf.rotation, action)) + run.anim = { kind = "move", index = i, steps = 0, distance = pf.width, timer = 0, + dir = G.moveDirection(pf.rotation, action), wall = G.leadingWall(pf, action), + phase = "check", ox = 0, oz = 0 } + end + if p then p.inputLocked = true end + return true +end + +function G.attach(ow, save) + local def = ow and ow.map and ow.map.def + if not (def and save) then return nil end + save.hgssGymmick = G.initialState() + local run = { state = save.hgssGymmick } + run.pfs = G.platforms(run.state) + def.gen4PropFollow = function(o) tick(o, run) end + def.gen4StepHook = function(o, x, z) return step(o, run, x, z) end + def.gen4CollisionHook = function(map, x, z) + local _, behaviour = mapQueries({ map = map }) + return G.collides(run.pfs, x, z, behaviour) + end + publish(ow, run) + return run +end + +return G diff --git a/src/world/HgssEcruteakGym.lua b/src/world/HgssEcruteakGym.lua new file mode 100644 index 000000000..b70ca9e7e --- /dev/null +++ b/src/world/HgssEcruteakGym.lua @@ -0,0 +1,54 @@ +-- Morty's gym in Ecruteak City (HeartGold / SoulSilver). +-- +-- pokeheartgold src/field/gymmick/ecruteak.c and gymmick_init.c: +-- +-- * InitEcruteakGym (the gym's init script): four candles, all lit. +-- * GymmickInit_Ecruteak: the room is fogged to black -- fog offset 0, every +-- density step 127, colour 0 -- with map objects left out of the fog, so +-- the people show and the floor does not. Each of the four mediums +-- (objects 2..5) has build model 128 placed on her (`gym04spot`, material +-- `dun_spot`, out of the fog). Its texture, `g04_light`, is black with a +-- soft transparent hole -- a darkness mask, not a glow -- so under the +-- black fog it adds nothing visible; it is placed all the same, and an +-- extinguished candle's is culled. +-- * ExtinguishEcruteakGymCandle: 30 frames, then the light shrinks over 4 +-- and goes out, the save remembers, and the medium becomes SPRITE_ITAKO_ +-- (250), the one with no candle. +-- +-- The light sits on the medium's position vector, which is where the +-- cartridge's SysTask puts it while she walks up to the player. + +local G = {} + +G.CANDLES = 4 +G.FIRST_OBJECT = 2 -- obj_T27GYM0101_itako .. _4 are local ids 2..5 +G.LIGHT_MODEL = 128 +G.FOG = { 0, 0, 0 } +G.OUT_SPRITE = 250 -- SPRITE_ITAKO_ + +function G.initialState() return { kind = "ecruteak", candles = { false, false, false, false } } end + +function G.candleOf(localId) + local i = (tonumber(localId) or -1) - G.FIRST_OBJECT + 1 + if i >= 1 and i <= G.CANDLES then return i end + return nil +end + +-- the lights, one per lit candle, on its medium; `scale` shrinks one going out +-- `heightAt(x, z)` is the ground's height there (the medium's position +-- vector stands on it; at 0 the light would sit under the raised path) +function G.props(state, npcs, dying, heightAt) + local out = {} + for _, npc in ipairs(npcs or {}) do + local i = npc.def and G.candleOf(npc.def.localId) + if i and not state.candles[i] and not npc.hidden then + local x, z = npc.px + 8, npc.py + 8 + local p = { model = G.LIGHT_MODEL, x = x, y = heightAt and heightAt(x, z) or 0, z = z } + if dying and dying.index == i then p.scale = 1 / dying.factor end + out[#out + 1] = p + end + end + return out +end + +return G diff --git a/src/world/HgssRoamers.lua b/src/world/HgssRoamers.lua new file mode 100644 index 000000000..4ffb726bd --- /dev/null +++ b/src/world/HgssRoamers.lua @@ -0,0 +1,112 @@ +-- HeartGold / SoulSilver's roamers: the tables pokeheartgold's field_roamer.c +-- and constants/roamer.h hold, in the shape Gen4Roamers reads (generated from +-- them: sRoamerLocations, sRoamerAdjacencyTable, Save_CreateRoamerByID). +-- +-- Raikou and Entei long-hop only within Johto (locations 0..15), Latias and +-- Latios only within Kanto (16..40) -- RoamerLocationSetRandom's loc_min / +-- loc_num -- so each slot carries its `region`. + +local H = {} + +local JOHTO, KANTO = {}, {} +for i = 0, 15 do JOHTO[#JOHTO + 1] = i end +for i = 16, 40 do KANTO[#KANTO + 1] = i end + +H.SLOTS = { + [0] = { name = "RAIKOU", species = 243, level = 40, region = JOHTO }, + [1] = { name = "ENTEI", species = 244, level = 40, region = JOHTO }, + [2] = { name = "LATIAS", species = 380, level = 35, region = KANTO }, + [3] = { name = "LATIOS", species = 381, level = 35, region = KANTO }, +} +H.SLOT_COUNT = 4 + +H.ROUTES = { + [0] = { header = 33, map = "R29", what = "Route 29" }, + [1] = { header = 34, map = "R30", what = "Route 30" }, + [2] = { header = 35, map = "R31", what = "Route 31" }, + [3] = { header = 36, map = "R32", what = "Route 32" }, + [4] = { header = 37, map = "R33", what = "Route 33" }, + [5] = { header = 38, map = "R34", what = "Route 34" }, + [6] = { header = 39, map = "R35", what = "Route 35" }, + [7] = { header = 40, map = "R36", what = "Route 36" }, + [8] = { header = 41, map = "R37", what = "Route 37" }, + [9] = { header = 42, map = "R38", what = "Route 38" }, + [10] = { header = 43, map = "R39", what = "Route 39" }, + [11] = { header = 44, map = "R42", what = "Route 42" }, + [12] = { header = 45, map = "R43", what = "Route 43" }, + [13] = { header = 46, map = "R44", what = "Route 44" }, + [14] = { header = 47, map = "R45", what = "Route 45" }, + [15] = { header = 48, map = "R46", what = "Route 46" }, + [16] = { header = 9, map = "R01", what = "Route 1" }, + [17] = { header = 10, map = "R02", what = "Route 2" }, + [18] = { header = 11, map = "R03", what = "Route 3" }, + [19] = { header = 12, map = "R04", what = "Route 4" }, + [20] = { header = 13, map = "R05", what = "Route 5" }, + [21] = { header = 14, map = "R06", what = "Route 6" }, + [22] = { header = 15, map = "R07", what = "Route 7" }, + [23] = { header = 16, map = "R08", what = "Route 8" }, + [24] = { header = 17, map = "R09", what = "Route 9" }, + [25] = { header = 18, map = "R10", what = "Route 10" }, + [26] = { header = 19, map = "R11", what = "Route 11" }, + [27] = { header = 20, map = "R12", what = "Route 12" }, + [28] = { header = 21, map = "R13", what = "Route 13" }, + [29] = { header = 22, map = "R14", what = "Route 14" }, + [30] = { header = 23, map = "R15", what = "Route 15" }, + [31] = { header = 24, map = "R16", what = "Route 16" }, + [32] = { header = 25, map = "R17", what = "Route 17" }, + [33] = { header = 26, map = "R18", what = "Route 18" }, + [34] = { header = 91, map = "W19", what = "Route 19" }, + [35] = { header = 92, map = "W20", what = "Route 20" }, + [36] = { header = 93, map = "W21", what = "Route 21" }, + [37] = { header = 27, map = "R22", what = "Route 22" }, + [38] = { header = 28, map = "R24", what = "Route 24" }, + [39] = { header = 30, map = "R26", what = "Route 26" }, + [40] = { header = 32, map = "R28", what = "Route 28" }, +} +H.ROUTE_COUNT = 41 + +H.NEARBY = { + [0] = { 1, 15 }, + [1] = { 0, 2 }, + [2] = { 1, 3, 7 }, + [3] = { 2, 4, 7 }, + [4] = { 3, 5 }, + [5] = { 4, 6 }, + [6] = { 5, 7 }, + [7] = { 2, 3, 8 }, + [8] = { 7, 9, 11 }, + [9] = { 8, 10, 11 }, + [10] = { 9 }, + [11] = { 8, 9, 12, 13 }, + [12] = { 11, 13 }, + [13] = { 11, 12, 15 }, + [14] = { 13, 15 }, + [15] = { 0, 14 }, + [16] = { 17, 37 }, + [17] = { 16, 37, 18 }, + [18] = { 17, 19 }, + [19] = { 18, 20, 38 }, + [20] = { 19, 21, 22, 23, 24, 38 }, + [21] = { 22, 23, 26 }, + [22] = { 20, 21, 23, 31 }, + [23] = { 20, 21, 22, 25, 27 }, + [24] = { 19, 20, 25, 38 }, + [25] = { 23, 24, 27 }, + [26] = { 21, 27 }, + [27] = { 23, 25, 26, 28 }, + [28] = { 27, 29 }, + [29] = { 28, 30 }, + [30] = { 29, 33, 34 }, + [31] = { 22, 37 }, + [32] = { 31, 33 }, + [33] = { 30, 32, 34 }, + [34] = { 30, 33 }, + [35] = { 34, 36 }, + [36] = { 16, 34 }, + [37] = { 16, 17, 39, 40 }, + [38] = { 19, 20, 24 }, + [39] = { 37, 40 }, + [40] = { 37, 39, 24 }, +} + +return H diff --git a/src/world/Map.lua b/src/world/Map.lua index 307240081..5f041000f 100644 --- a/src/world/Map.lua +++ b/src/world/Map.lua @@ -1651,6 +1651,10 @@ function Map:isCounterCell(cx, cy) local t = self.tileset.behaviourBytes and self:cellBehaviour(cx, cy) or self:cellTile(cx, cy) if self.tileset.id=='TILESET_GEN4_STANDIN' and t==0x80 then return true end + -- Black / White's counter is behaviour 0xD4 (Gen5Field: a room's counters + -- are byte 4 = 0xD4) -- every Pokemon Center nurse and mart clerk is + -- spoken to across one + if self.tileset.id=='TILESET_GEN4_STANDIN' and t==0xD4 and self.def and self.def.gen5 then return true end for _, c in ipairs(self.tileset.counterTiles or {}) do if c == t then return true end end diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 93157e051..3429b1153 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -552,7 +552,20 @@ local function normaliseGfxName(name) end local gen4NormalisedNames = nil +-- HEARTGOLD AND SOULSILVER: the cache says so (gen4_version.family), and their +-- graphics ids go straight to an archive member through pret's own table +-- (Gen4HgssObjectGfx) -- the member labels are numbers, so there is no name +-- to join on. +local function isHgssData(data) + return data and data.gen4_version and data.gen4_version.family == "hgss" or false +end local function gen4SpriteFor(data, graphicsId) + if isHgssData(data) then + local okH, Hg = pcall(require, "src.import.Gen4HgssObjectGfx") + local row = okH and Hg.byId[tonumber(graphicsId) or -1] + if not row then return nil, nil end + return ("SPRITE_G4_%03d"):format(row[1]), row[2] + end local ok, Gfx = pcall(require, "src.import.Gen4ObjectGfx") local name = ok and Gfx and Gfx.name(graphicsId) or nil local sprites = data and data.gen4_overworld and data.gen4_overworld.sprites @@ -577,10 +590,32 @@ OverworldState.gen4SpriteFor = gen4SpriteFor local function resolveGraphicsVar(save, obj) local id = tonumber(obj and obj.graphicsId) - if not id or id < GFX_VAR_FIRST or id > GFX_VAR_LAST then return obj end local data = Game and Game.data + -- AN APRICORN TREE WEARS ITS FRUIT (HGSS): SPRITE_BONGURI (262) bare, else + -- SPRITE_BONGURI + colour (sub_020640A4's table, 263 red .. 269 black) + if obj and obj.apricornTree and save then + local A = require("src.world.HgssApricorns") + if A.treeHasFruit(save, obj.apricornTree) then + local colour = A.TREES[obj.apricornTree + 1] + local sprite, name = colour and gen4SpriteFor(data, 262 + colour) + if sprite then + local copy = {} + for k, v in pairs(obj) do copy[k] = v end + copy.graphicsId, copy.sprite, copy.spriteName = 262 + colour, sprite, name + return copy + end + end + return obj + end + -- HGSS resolves exactly as Platinum does: map_object.c maps sprite ids + -- 101..117 to VAR_OBJ_GFX_BASE + (id - 101), so SPRITE_VAR_2 (102) reads + -- VAR_OBJ_1 -- what Route 29's GetFriendSprite VAR_OBJ_1 fills for Lyra. + -- (An earlier HGSS-only base of 100, read off SPRITE_VAR_0's #define, put + -- every friend one var out.) + local first = GFX_VAR_FIRST + if not id or id < first or id > first + (GFX_VAR_LAST - GFX_VAR_FIRST) then return obj end local vars = save and save.gen4Vars - local slot = GFX_VAR_BASE + (id - GFX_VAR_FIRST) + local slot = GFX_VAR_BASE + (id - first) local resolved = vars and tonumber(vars[slot]) local sprite, name = nil, nil if resolved and resolved ~= 0 then sprite, name = gen4SpriteFor(data, resolved) end @@ -593,7 +628,7 @@ local function resolveGraphicsVar(save, obj) gfxVarReported[slot] = true Logger.warn("gen4 object: graphics id %d is var_%d (var 0x%04X) and it " .. "holds %s -- this object keeps the placeholder and will " - .. "not draw", id, id - GFX_VAR_FIRST, slot, + .. "not draw", id, id - first, slot, resolved and tostring(resolved) or "nothing yet") end return obj @@ -1007,6 +1042,9 @@ function OverworldState:stampClosedDoors() end function OverworldState:setMap(mapId, x, y, facing, opts) + -- BLACK / WHITE'S ARRIVAL GUARD: the warp you land on stays disarmed until + -- you step off it (see checkGen4EntranceWarp's gen5 branch) + self.gen5ArrivalMap, self.gen5ArrivalX, self.gen5ArrivalY = mapId, x, y local fromMapId = self.map and self.map.id -- the header being left, for Platinum's area sign (which compares names) local fromHeader = self.map and self.map.def and self.map.def.header @@ -2656,6 +2694,9 @@ function OverworldState:noteCellChange() if self.lastCellX ~= p.cellX or self.lastCellY ~= p.cellY then self.lastCellX, self.lastCellY = p.cellX, p.cellY self.cellSerial = (self.cellSerial or 0) + 1 + if self.gen5ArrivalMap and (p.cellX ~= self.gen5ArrivalX or p.cellY ~= self.gen5ArrivalY) then + self.gen5ArrivalMap = nil + end end end @@ -2790,6 +2831,8 @@ function OverworldState:drainPendingScripts() -- still fired. if not self.runner:isRunning() and (GameVersion.isGen2() or GameVersion.isGen3()) then self:syncObjectVisibility() + elseif not self.runner:isRunning() and GameVersion.isGen4() then + self:refreshGen4VarSprites() end end end @@ -2868,6 +2911,39 @@ function OverworldState:reassertEntities(why) end end +-- A GEN 4 OBJECT DRAWN FROM A GRAPHICS VAR, once the map's entry script has +-- filled the var. The NPC list is built before that script runs (it is +-- queued), so the object was built with the placeholder; the cartridge runs +-- the script first. Only placeholders are touched: everything else on the +-- map stays exactly as built. HGSS's friend is the case that showed it -- +-- Route 29's `GetFriendSprite VAR_OBJ_1` dresses Lyra, who stood invisible +-- through her own catching demonstration. +function OverworldState:refreshGen4VarSprites() + if not (self.map and self.map.def and GameVersion.isGen4()) then return end + local mapId = self.map.id + for i, npc in ipairs(self.npcs or {}) do + local def = npc.def + if def and (def.noSprite == "runtime_variable" or def.apricornTree ~= nil) then + local obj + for _, o in ipairs(self.map.def.objects or {}) do + if o.index == def.index then obj = o break end + end + local resolved = obj and objectHome(nil, mapId, obj) + if resolved and resolved.sprite and not resolved.noSprite then + local fresh = pooledNPC(self.npcPool, Game.data, mapId, obj) + if fresh ~= npc then + fresh.cellX, fresh.cellY, fresh.px, fresh.py = npc.cellX, npc.cellY, npc.px, npc.py + fresh.facing, fresh.hidden = npc.facing, npc.hidden + self.npcs[i] = fresh + for j, e in ipairs(self.entities or {}) do + if e == npc then self.entities[j] = fresh end + end + end + end + end + end +end + function OverworldState:syncObjectVisibility(only) if not (self.map and self.map.def) then return end local mapId = self.map.id @@ -3160,6 +3236,10 @@ function OverworldState:update(dt) self:tickPacifidlogLogs() -- ...and the PC's screen, which blinks itself on when you use one self:tickPcScreen() + -- ...and props a map's script ties to its people (HGSS's Morty's gym: + -- each medium carries her candle's light), `def.gen4PropFollow(self)` + local followProps = self.map and self.map.def and self.map.def.gen4PropFollow + if followProps then followProps(self) end -- ...and the Petalburg gym doors, which are a five-frame slide self:tickGymDoorSlide() -- ...and the rotating gates, which swing through their quarter turn @@ -3360,6 +3440,9 @@ function OverworldState:update(dt) -- ENTRY -- after this engine has already built the NPC list. So every -- object that a Hoenn map's own entry script hides stayed on screen for the -- whole visit, and only went away if the player left and came back. + if scriptWasRunning and not self.runner:isRunning() and GameVersion.isGen4() then + self:refreshGen4VarSprites() + end if scriptWasRunning and not self.runner:isRunning() and (GameVersion.isGen2() or GameVersion.isGen3()) then self:syncObjectVisibility() @@ -4069,6 +4152,21 @@ function OverworldState:checkGen4EntranceWarp(dir) local tx, ty = Collision.target(p.cellX, p.cellY, dir) if not map:gen4TerrainBlocked(tx, ty) then return false end + -- BLACK / WHITE HAS NO DOOR BEHAVIOURS: a door's or a staircase's cell is + -- a plain solid tile (byte 4 = 0x01, the same as the wall beside it) and + -- the warp event on it is the whole of what makes it an entrance. So a + -- press into a solid cell carrying a warp takes it, and a press off the + -- warp the player stands on -- an exit mat, a route's edge -- takes that. + if map.def and map.def.gen5 then + local ahead = map:warpAtCell(tx, ty) + if ahead then self:takeWarp(ahead.def) return true end + local here = map:warpAtCell(p.cellX, p.cellY) + local arrived = self.gen5ArrivalMap == map.id and self.gen5ArrivalX == p.cellX + and self.gen5ArrivalY == p.cellY + if here and not arrived then self:takeWarp(here.def) return true end + return false + end + -- THE DOOR IN FRONT. The warp event sits on the door, not on the ground the -- player stands on, so it is the target that is asked. if map.gen4DoorAt and map:gen4DoorAt(tx, ty) then @@ -4092,6 +4190,32 @@ function OverworldState:checkGen4EntranceWarp(dir) return true end +-- BLACK / WHITE: hand the player to the zone that owns the chunk they now +-- stand in. A zone is a set of 32 x 32 chunks of one matrix (the matrix's +-- zone table, written as `headers` on gen4_map_matrices), and its map def is +-- the bounding box of them -- so a box can contain another zone's chunk +-- (Route 2 round Accumula) and the edge seams alone cannot say whose ground +-- this is. Same seamless crossing the edge connections use. +function OverworldState:gen5ChunkHandoff() + local def = self.map and self.map.def + local data = Game.data + local mx = data and data.gen4_map_matrices and def and data.gen4_map_matrices[def.layout] + if not (mx and mx.headers and mx.width) then return false end + local p = self.player + local wx, wy = (def.originX or 0) + p.cellX, (def.originY or 0) + p.cellY + local cx, cy = math.floor(wx / 32), math.floor(wy / 32) + if cx < 0 or cy < 0 or cx >= mx.width or cy >= (mx.height or 0) then return false end + local zone = mx.headers[cy * mx.width + cx + 1] + if not zone or zone == 0 or zone == def.header then return false end + local target = ("Z%03d"):format(zone) + local tdef = data.maps and data.maps[target] + if not (tdef and tdef.layout == def.layout) then return false end + self:setMap(target, wx - (tdef.originX or 0), wy - (tdef.originY or 0), p.facing, + { seamless = true, keepMusic = true }) + self.pendingSeamMusic = target + return true +end + function OverworldState:checkGen3ArrowWarp(dir) local p = self.player -- duck-typed map stubs (the editor, the save converter, a mod's fixture) @@ -6354,7 +6478,19 @@ function OverworldState:followCamera() local rise = ground:rise(target.px + 8, target.py + 8) or 0 camera.spriteCenterX, camera.spriteCenterY = 8, -4 - rise end - camera:follow(target.px, target.py, Game.renderer:worldViewSize()) + -- WHERE THE TARGET'S FEET ARE ON THE GROUND, in every camera mode: the + -- live pass picks its chunk rows by it and the 3D cameras stand on it. The + -- view follows the RAISED sprite, so anything worked back from the view + -- lands rows above the floor of a tall map -- Falkner's gym drew black, and + -- its tilted camera stood 230 units under the walkway it was looking for. + -- A SCRIPTED SCREEN SHAKE (HGSS `ScreenShake`, Gen4Commands) nudges the + -- look-at target; zero otherwise + local sx, sy = 0, 0 + if self.gen4ShakeOffset then sx, sy = self.gen4ShakeOffset[1], self.gen4ShakeOffset[2] end + if ground then + ground.cameraFeetX, ground.cameraFeetZ = target.px + 8 + sx, target.py + 8 + end + camera:follow(target.px + sx, target.py - sy, Game.renderer:worldViewSize()) end -- The Gen 4 ground when a FREE camera owns the frame, or nil. @@ -11435,6 +11571,17 @@ function OverworldState:showMapText(textConst, npc, onDone) end end end + -- BLACK / WHITE'S is 0x8011: the nurse's `applymovement 0x8011` turns + -- her, and the Cut tree's shared script removes `0x8011` + if GameVersion.isGen5() and npc then + local lid = npc.localId or (npc.def and npc.def.localId) + if lid then + local okG4, Gen4Commands = pcall(require, "src.script.Gen4Commands") + if okG4 and Gen4Commands and Gen4Commands.setVar then + Gen4Commands.setVar(Game.save, 0x8011, lid) + end + end + end self.runner:run(script, { npc = npc, onDone = onDone, source = mapScripts.talkSource(self.map.id, textConst) }) return @@ -12128,6 +12275,10 @@ function OverworldState:onStepComplete() if not (Game and Game.save) then return end if GameVersion.isGen4() then require("src.pokemon.Gen4GameRecords").increment(Game.save, 0) + -- THE TOWN MAP'S HISTORY DOTS: overworld_map_history.c pushes on every + -- matrix-cell change outside, and `remember` only pushes when the cell + -- differs from the last entry, so calling it per step costs a compare + require("src.ui.Gen4TownMap").remember(Game) elseif GameVersion.isGen3() then require("src.pokemon.Gen3GameStats").increment(Game.save, 5) end @@ -12346,6 +12497,16 @@ function OverworldState:onStepComplete() end end + -- BLACK / WHITE'S ZONES ARE CHUNKS, NOT RECTANGLES: Route 2's box wraps + -- round Accumula's single chunk, so the map a step lands in is the one the + -- matrix's zone table names for that chunk + if not warpFirst and self.map.def and self.map.def.gen5 and self:gen5ChunkHandoff() then return end + + -- A MAP'S OWN STEP RULE (HGSS: a button on Clair's platforms), which + -- FieldSystem_ProcessStep asks before anything else + local stepHook = not warpFirst and self.map.def and self.map.def.gen4StepHook + if stepHook and stepHook(self, p.cellX, p.cellY) then return end + -- hand-ported step triggers (Pallet intro, Saffron gate guards, ...) local hooks = mapScripts.get(self.map.id) if not warpFirst and hooks and hooks.onStep then @@ -13988,7 +14149,9 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) -- would switch on fourteen other behaviour-driven branches in Map.lua -- at the same time, and each wants its own look. local stepsOut - if GameVersion.isGen4() then + -- Black / White arrive on their door the same way (the door cell is a + -- solid tile there, so standing in it is never where a walk ends) + if GameVersion.isGen4() or GameVersion.isGen5() then stepsOut = self.player.facing == "down" else stepsOut = self.map:isDoorTileCell(self.player.cellX, self.player.cellY) diff --git a/src/world/Player.lua b/src/world/Player.lua index 5d9a97a68..2ddf2a53b 100644 --- a/src/world/Player.lua +++ b/src/world/Player.lua @@ -289,6 +289,16 @@ function Player:refreshForm(data) -- there is no bicycle either, so both sheets become the mon's local monId = pokemonModeSprite(data) if monId then walkId, bikeId = monId, monId end + -- HEARTGOLD / SOULSILVER'S ROCKET DISGUISE (PLAYER_STATE_ROCKET): + -- SPRITE_RHERO 258 / SPRITE_RHEROINE 259 while `save.hgssRocketCostume` + local okG, Game = pcall(require, "src.core.Game") + local save = okG and Game and Game.save + if save and save.hgssRocketCostume then + local okO, OW = pcall(require, "src.world.OverworldController") + local girl = save.player and save.player.gender == "girl" + local id = okO and OW.gen4SpriteFor and OW.gen4SpriteFor(data, girl and 259 or 258) + if id and data.sprites and data.sprites[id] then walkId, bikeId = id, id end + end -- The pose follows the character, so it is refreshed even when the walking -- sheets did not change (a rip that only produced Kris's fish art). self:refreshFishTiles(data) diff --git a/tools/gen4_art_extract/main.lua b/tools/gen4_art_extract/main.lua index 4a1d079a8..21f97939d 100644 --- a/tools/gen4_art_extract/main.lua +++ b/tools/gen4_art_extract/main.lua @@ -22,10 +22,12 @@ local MODULES = { trainer_card = { module = "src.import.Gen4TrainerCardArt", dir = "trainer_card_art", cache = "gen4_trainer_card_art" }, summary = { module = "src.import.Gen4SummaryArt", dir = "summary_art", cache = "gen4_summary_art", dataCache = "gen4_summary_ink" }, box = { module = "src.import.Gen4BoxArt", dir = "box", cache = "gen4_box_art", dataCache = "gen4_box_ink" }, + townmap = { module = "src.import.Gen4TownMapArt", dir = "town_map_art", cache = "gen4_town_map_art", dataCache = "gen4_town_map_ink" }, mainmenu = { module = "src.import.Gen4MainMenuArt", dir = "main_menu", cache = "gen4_main_menu_art", dataCache = "gen4_main_menu_ink" }, shop = { module = "src.import.Gen4ShopArt", dir = "shop_art", cache = "gen4_shop_art" }, battle = { module = "src.import.Gen4BattleArt", dir = "battle_art", cache = "gen4_battle_art", dataCache = "gen4_battle_anims" }, poketch = { module = "src.import.Gen4PoketchArt", dir = "poketch_art", cache = "gen4_poketch_art", dataCache = "gen4_poketch_ink" }, + startmenu = { module = "src.import.Gen4StartMenuArt", dir = "start_menu_art", cache = "gen4_start_menu_art", dataCache = "gen4_start_menu_ink" }, evolution = { module = "src.import.Gen4EvolutionArt", dir = "evolution_art", cache = "gen4_evolution_art", dataCache = "gen4_evolution_ink" }, } diff --git a/tools/gen4_bag_card_check.lua b/tools/gen4_bag_card_check.lua index 7a886595c..3b6555128 100644 --- a/tools/gen4_bag_card_check.lua +++ b/tools/gen4_bag_card_check.lua @@ -150,10 +150,13 @@ if cacheDir then check(bag ~= nil, 'the cache carries gen4_bag_art (tools/gen4_art_extract bag)') if bag then local need = { "item_highlight_0", "item_highlight_1", "pocket_highlight_0", "arrow_left", "arrow_right", - "item_return", "entry_icons", "special_chars", "sub_borders", "sub_dial", "dial_button_0" } + "item_return", "entry_icons", "special_chars", "sub_borders", "sub_dial", "dial_button_0", + -- the touch screen's faces and the action-menu / moving pieces + "dial_button_1", "dial_button_2", "shockwave_0", "shockwave_1", "moving_bar", + "item_highlight_1", "pocket_highlight_1" } for p = 0, 7 do need[#need + 1] = "bag_male_" .. p; need[#need + 1] = "bag_female_" .. p - need[#need + 1] = ("pocket_button_%d_0"):format(p) + for f = 0, 2 do need[#need + 1] = ("pocket_button_%d_%d"):format(p, f) end end local missing = {} for _, k in ipairs(need) do if not bag[k] then missing[#missing + 1] = k end end @@ -173,7 +176,14 @@ if cacheDir then for _, l in ipairs({ "normal", "cobalt", "bronze", "silver", "gold", "black", "no_dex" }) do need[#need + 1] = "front_" .. l; need[#need + 1] = "back_" .. l end - for i = 0, 7 do need[#need + 1] = "badge_" .. i end + for i = 0, 7 do + need[#need + 1] = "badge_" .. i + -- TrainerCase_DrawBadgeDirt's four palette rows + for d = 0, 3 do need[#need + 1] = ("badge_%d_%d"):format(i, d) end + end + -- the polish sparkles, the button's press effect and its three faces + for f = 0, 3 do need[#need + 1] = "two_sparkles_" .. f; need[#need + 1] = "four_sparkles_" .. f end + for f = 0, 2 do need[#need + 1] = "button_effect_" .. f; need[#need + 1] = "case_button_" .. f end local missing = {} for _, k in ipairs(need) do if not card[k] then missing[#missing + 1] = k end end check(#missing == 0, 'gen4_trainer_card_art carries every key the card draws (missing: ' .. table.concat(missing, ", ") .. ')') diff --git a/tools/gen4_battle_bag_check.lua b/tools/gen4_battle_bag_check.lua new file mode 100644 index 000000000..3414df8b6 --- /dev/null +++ b/tools/gen4_battle_bag_check.lua @@ -0,0 +1,251 @@ +-- Run: python tools/run_lua_check.py tools/gen4_battle_bag_check.lua +-- +-- PLATINUM'S BATTLE BAG AND THE BATTLE PARTY LIST'S PAGES, headless. +-- +-- * the battle pockets are BattleBag_Init's: each bag item filed under every +-- pocket its ITEM_PARAM_BATTLE_POCKET mask names, six to a page, pages +-- wrapping (BattleBagTask_ChangePocketPage) +-- * the touch rects and cursor tables are the decomp's, including the +-- go-back-to-previous links and the hidden-until-a-key cursor +-- * "Item used last" opens the item's own page; an item no longer carried +-- is forgotten (BattleBag_CanUseLastUsedItem) +-- * TryUseItem refuses a ball at two foes / a hidden foe / a substitute +-- * the party list's prev / next skip eggs; its learn mode refuses an HM +-- and answers 1..4 or nil; an ETHER picks a move before it is used +-- * the art and the inks are in the cache (Gen4BattleArt bbag_* / b*_bg) + +package.path = './?.lua;./?/init.lua;' .. package.path +love = love or {} + +local PASS, FAIL = 0, 0 +local function check(cond, label) + if cond then PASS = PASS + 1 else FAIL = FAIL + 1; print('FAIL: ' .. label) end +end + +local cacheDir = arg and arg[1] +if not cacheDir or cacheDir == '' then + print('usage: python tools/run_lua_check.py tools/gen4_battle_bag_check.lua ') + os.exit(2) +end +cacheDir = cacheDir:gsub('[/\\]$', '') +local function load(name) + local f = loadfile(cacheDir .. '/' .. name .. '.lua') + return f and f() +end + +require('src.core.GameVersion').set('platinum') +local items = load('items') or {} +local art = load('gen4_battle_art') or {} +local anims = load('gen4_battle_anims') or {} +local moves = load('moves') or {} + +-- ============================ 1. the cache carries the screens +for _, key in ipairs({ 'bbag_menu', 'bbag_pocket', 'bbag_use_0', 'bbag_use_3', + 'bbag_pocketbtn_0_0', 'bbag_pocketbtn_3_2', 'bbag_item_0_0', 'bbag_item_off', + 'bbag_last_0', 'bbag_last_3', 'bbag_usebtn_2_1', 'bbag_cancel_2', + 'bbag_prev_3', 'bbag_next_0', 'bsummary_bg', 'bsummary_fg', 'bmoves_fg', + 'bmovesum_bg', 'brestore_fg', 'blearn_bg', 'bconfirm_fg', 'bparty_prev_0', + 'bparty_next_3', 'bparty_movebtn_0', 'bparty_movebtn_3', 'bparty_learnbtn_0', + 'bparty_msbtn_2', 'bparty_confirm_0', 'bsummary_exp' }) do + check(type(art[key]) == 'table' and art[key].path, 'gen4_battle_art carries ' .. key) +end +check(art.bbag_menu and art.bbag_menu.width == 256 and art.bbag_menu.height == 192, 'the bag views are 256x192') +check(art.bbag_pocketbtn_0_0 and art.bbag_pocketbtn_0_0.width == 128 and art.bbag_pocketbtn_0_0.height == 72, + 'a pocket button is 16x9 tiles') +check(art.bbag_item_0_0 and art.bbag_item_0_0.width == 128 and art.bbag_item_0_0.height == 48, + 'an item button is 16x6 tiles') +check(art.bbag_last_0 and art.bbag_last_0.width == 208 and art.bbag_last_0.height == 40, + 'the Item used last / USE bar is 26x5 tiles') +check(art.bsummary_exp and art.bsummary_exp.width == 72, 'the EXP fills are nine tiles') +check(anims.bagInk and anims.bagInk.row0 and anims.bagInk.row4 and anims.bagInk.row0[3], + 'gen4_battle_anims carries the bag windows\' palettes 0 and 4') +check(anims.partyInk13 and anims.partyInk13[15] and anims.partyInk13[1], + 'and the party pages\' palette 13') +local dsrc = io.open('src/core/Data.lua'):read('a') +check(dsrc:find('"gen4_battle_art"', 1, true) and dsrc:find('"gen4_battle_anims"', 1, true), + 'Data.lua loads both modules (by literal name)') + +-- ============================ 2. a fake game, a stocked bag +local function input(keys) + keys = keys or {} + return { wasPressed = function(_, k) return keys[k] == true end, + isDown = function() return false end } +end +local stack = { list = {} } +function stack:push(s) self.list[#self.list + 1] = s end +function stack:pop() self.list[#self.list] = nil end +function stack:top() return self.list[#self.list] end + +local function newGame(inv, order) + local game = { + data = { items = items, moves = moves, text = {}, pokemon = {}, gen4_battle_art = art, + gen4_battle_anims = anims }, + save = { inventory = inv, bagOrder = order, party = {} }, + stack = stack, input = input(), + } + return game +end + +-- Potion 17, Super Potion 26, Ether 38 (HP/PP); Antidote 18, Full Heal 27 +-- (status); Poke Ball 4, Great Ball 3 (balls); X Attack 57 (battle items) +local inv, order = {}, {} +for _, row in ipairs({ { 4, 10 }, { 17, 3 }, { 26, 2 }, { 38, 1 }, { 18, 4 }, { 27, 1 }, + { 3, 2 }, { 57, 1 }, { 25, 1 }, { 24, 1 }, { 23, 1 }, { 22, 1 }, { 21, 1 } }) do + inv[row[1]] = row[2]; order[#order + 1] = row[1] +end +local Bag = require('src.ui.Gen4BattleBag') +local battle = { data = nil, gen4CursorActive = false } +local game = newGame(inv, order) +battle.data, battle.game = game.data, game +local bag = Bag.new(game, { battle = battle }) + +local function ids(list) local t = {} for _, r in ipairs(list) do t[#t + 1] = r.id end return t end +local function has(list, id) for _, r in ipairs(list) do if r.id == id then return true end end return false end +check(has(bag.pockets[0], 17) and has(bag.pockets[0], 26) and has(bag.pockets[0], 38), + 'POTION, SUPER POTION and ETHER are HP/PP RESTORE (mask bits 2 and 4)') +check(has(bag.pockets[1], 18) and has(bag.pockets[1], 27), 'ANTIDOTE and FULL HEAL are STATUS HEALERS') +check(has(bag.pockets[2], 4) and has(bag.pockets[2], 3) and not has(bag.pockets[0], 4), + 'the balls are POKe BALLS and nowhere else') +check(has(bag.pockets[3], 57), 'X ATTACK is a BATTLE ITEM') +-- field pocket order: the medicine pocket (Potion...) before the balls in a +-- pocket shared by none; within a pocket the bag's own order +local p0 = ids(bag.pockets[0]) +check(p0[1] == 17 and p0[2] == 26, 'within a field pocket the bag order holds: ' .. table.concat(p0, ',')) + +-- ============================ 3. paging +local n0 = #bag.pockets[0] +check(bag.pageCount[0] == math.floor((n0 - 1) / 6), 'pages = (items - 1) / 6, got ' .. tostring(bag.pageCount[0])) +bag.pocket = 0; bag:setScreen('pocket') +local function runPress() for _ = 1, 3 do bag:update(1 / 60) end end +if bag.pageCount[0] > 0 then + bag:pickPocket(8); runPress() + check(bag.cur.pages[0] == 1, 'NEXT turns the page') + bag:pickPocket(8); runPress() + check(bag.cur.pages[0] == 0, 'NEXT past the last page wraps to the first') + bag:pickPocket(7); runPress() + check(bag.cur.pages[0] == bag.pageCount[0], 'PREV from the first wraps to the last') + bag.cur.pages[0] = 0 +end +check(bag:itemAt(1, 0) and bag:itemAt(1, 0).id == 17, 'page position 1 is the first item') + +-- ============================ 4. the cursor +local Cursor = require('src.ui.Gen4SubMenuCursor') +battle.gen4CursorActive = false +bag:setScreen('menu') +game.input = input({ right = true }) +bag:update(1 / 60) +check(battle.gen4CursorActive == true and bag.cursor.index == 1, + 'the first key only shows the cursor (IsCursorVisible)') +bag:update(1 / 60) +check(bag.cursor.index == 3, 'right from HP/PP is POKe BALLS') +game.input = input({ down = true }); bag:update(1 / 60); bag:update(1 / 60) +check(bag.cursor.index == 6, 'down twice from POKe BALLS is CANCEL') +game.input = input({ up = true }); bag:update(1 / 60) +check(bag.cursor.index == 4, 'up from CANCEL goes back where it came from (prev link)') +game.input = input() +check(Cursor.hit(Bag.MENU_TOUCH, 10, 170) == 5 and Cursor.hit(Bag.MENU_TOUCH, 230, 170) == 6, + 'the menu touch rects: Item used last, CANCEL') +check(Cursor.hit(Bag.POCKET_TOUCH, 20, 170) == 7 and Cursor.hit(Bag.POCKET_TOUCH, 60, 170) == 8, + 'the pocket touch rects: prev, next') +check(Cursor.hit(Bag.USE_TOUCH, 100, 170) == 1, 'USE is the bar') + +-- ============================ 5. Item used last +local c = Bag.bagCursor(game) +c.lastItem, c.lastPocket = 26, 0 +bag:pickMenu(5); runPress() +check(bag.screen == 'use' and bag:itemAt((bag.cur.positions[0] or 0) + 1, 0).id == 26, + 'Item used last opens its own item') +inv[26] = nil +local bag2 = Bag.new(game, { battle = battle }) +check(Bag.bagCursor(game).lastItem == nil, 'an item no longer carried is forgotten') +inv[26] = 2 + +-- ============================ 6. TryUseItem's refusals +local foeA, foeB = { mon = { hp = 5 } }, { mon = { hp = 5 } } +battle.player = { mon = {} } +battle.enemy = foeA +battle.foesOf = function() return { foeA, foeB } end +bag2.pocket = 2 +check(bag2:refusal() ~= nil, 'no ball while two foes stand') +battle.foesOf = function() return { foeA } end +foeA.invulnerable = 'fly' +check(bag2:refusal() ~= nil, 'no ball at a foe out of sight') +foeA.invulnerable = nil; foeA.substituteHP = 10 +check(bag2:refusal() ~= nil, 'no ball at a substitute') +foeA.substituteHP = nil +check(bag2:refusal() == nil, 'otherwise the ball flies') +bag2.pocket = 0 +foeA.invulnerable = 'dig' +check(bag2:refusal() == nil, 'a POTION is not refused for a hidden foe') + +-- ============================ 7. the battle party list's pages +local Party = require('src.ui.Gen4BattleParty') +local function mon(species, hp, egg) return { species = species, hp = hp, stats = { hp = 20 }, level = 10, + moves = { { id = 1, pp = 10 }, { id = 15, pp = 5 } }, isEgg = egg, egg = egg } end +game.save.party = { mon(1, 10), mon(4, 0), mon(7, 5, true), mon(25, 3) } +local p = Party.new(game, { battle = battle }) +p.index = 1 +p:stepMon(1); check(p.index == 2, 'NEXT goes to the next member (a fainted one counts)') +p:stepMon(1); check(p.index == 4, '...and skips an egg') +p:stepMon(1); check(p.index == 1, '...and wraps') +p:stepMon(-1); check(p.index == 4, 'PREV wraps backwards') + +-- learn mode: an HM is refused, a move is answered, CANCEL answers nil +local answer = 'unset' +local learner = { species = 1, hp = 10, stats = { hp = 20 }, level = 10, + moves = { { id = 15, pp = 5 }, { id = 1, pp = 10 } } } +package.loaded['src.ui.MoveLearnMenu'] = { hmMoves = function() return { [15] = true } end } +stack.list = {} +local L = Party.new(game, { battle = battle, mode = 'learnMove', mon = learner, moveId = 53, + onChoose = function(i) answer = i end }) +stack:push(L) +check(L.screen == 'learn', 'learn mode opens on the learn page') +L:pickLearn(1); check(L.screen == 'confirm', 'a move opens FORGET / CANCEL') +L:pickConfirm(1); check(L.hmRefused == true and answer == 'unset', 'an HM cannot be forgotten') +L:pickConfirm(3); check(L.screen == 'learn', 'CANCEL goes back to the four moves') +L:pickLearn(2); L:pickConfirm(1) +check(answer == 2 and stack:top() ~= L, 'FORGET answers the slot and closes') +answer = 'unset'; stack.list = {} +local L2 = Party.new(game, { battle = battle, mode = 'learnMove', mon = learner, moveId = 53, + onChoose = function(i) answer = i end }) +stack:push(L2) +L2:pickLearn(5); L2:pickConfirm(1) +check(answer == nil, 'choosing the move to learn answers "don\'t learn"') + +-- item mode: an ETHER asks which move, everything else hands the Pokemon on +stack.list = {} +local moved, switched +local U = Party.new(game, { battle = battle, mode = 'useItem', itemId = 38, + onMove = function(m, i) moved = { m, i } end, onSwitch = function(m) switched = m end }) +stack:push(U) +U:pickSlot(1) +check(U.screen == 'restore', 'an ETHER opens RESTORE WHICH MOVE?') +U:pickRestore(2) +check(moved and moved[1] == game.save.party[1] and moved[2] == 2, '...and hands on the Pokemon and the move') +stack.list = {} +local U2 = Party.new(game, { battle = battle, mode = 'useItem', itemId = 17, + onSwitch = function(m) switched = m end }) +stack:push(U2) +U2:pickSlot(4) +check(switched == game.save.party[4] and stack:top() ~= U2, 'a POTION hands the Pokemon straight on') + +-- ============================ 8. the party ball wobble (SysTask_AnimatePartyBalls) +local G = require('src.battle.Gen4Battle') +local st = { phase = 0 } +G.ballWobbleStep(st, 50, 0) +check(st.phase == 0, 'below 87% the ball rests') +G.ballWobbleStep(st, 90, 3) +check(st.phase == 1 and st.delay == 13, 'at 87% it waits 10 + rand % 8') +local maxX, minX, frames = 0, 0, 0 +for _ = 1, 60 do + G.ballWobbleStep(st, 90, 0) + frames = frames + 1 + maxX = math.max(maxX, st.x or 0); minX = math.min(minX, st.x or 0) + if st.phase == 0 then break end +end +check(maxX == 3 and minX == -3, 'it rocks 3 px each way (0x180 a frame for two frames), got ' + .. maxX .. ' / ' .. minX) +check(st.phase == 0 and (st.x or 0) == 0, 'and settles back after the second bounce') + +print(('%d checks, %d failed'):format(PASS, FAIL)) +if FAIL > 0 then os.exit(1) end diff --git a/tools/gen4_battle_ui_harness/main.lua b/tools/gen4_battle_ui_harness/main.lua index 259d2e4dc..d3d5ac7d3 100644 --- a/tools/gen4_battle_ui_harness/main.lua +++ b/tools/gen4_battle_ui_harness/main.lua @@ -116,6 +116,35 @@ function love.load() return battle end + -- a bag with every battle pocket filled, the HP pocket past one page + function stockBag(b, used) + local Bag = require("src.inventory.Bag") + local save = b.game.save + for _, row in ipairs({ { 17, 5 }, { 26, 3 }, { 25, 2 }, { 24, 1 }, { 23, 1 }, { 28, 2 }, + { 38, 4 }, { 18, 6 }, { 22, 2 }, { 21, 1 }, { 19, 3 }, { 20, 1 }, + { 27, 2 }, { 4, 12 }, { 3, 5 }, { 2, 1 }, { 55, 1 }, { 57, 2 }, { 58, 1 } }) do + Bag.add(save, row[1], row[2], Data) + end + if used then + local c = require("src.ui.Gen4BattleBag").bagCursor(b.game) + c.lastItem, c.lastPocket = 26, 0 + end + end + function makeDouble(b) + local BattleState = require("src.battle.BattleState") + b.double = true + local partner = b.game.save.party[2] + b:placeBattler(2, BattleState.makeBattler(Data, partner, true, b.game.save)) + local foe2 = Pokemon.new(Data, 403, 5) + local B = BattleState.makeBattler(Data, foe2, false) + b:placeBattler(3, B) + b.enemyParty = { b.enemy.mon, foe2 } + for _, x in ipairs({ b:battlerAt(2), b:battlerAt(3) }) do + x.shownHP = x.mon.hp + if x.name == nil then x.name = Data.pokemon[x.mon.species].name end + end + end + local SCENES = { { "intro", { ticks = 140 } }, { "menu", {} }, @@ -169,9 +198,131 @@ function love.load() { "party_select", { more = { { 25, 14 } } }, function(b) local P = require("src.ui.Gen4BattleParty") local s = P.new(b.game, { battle = b }) - s.index = 2; s.screen = "select"; s.selectIndex = 3 + s.index = 2; b.gen4CursorActive = true; s:setScreen("select", 3) + return s + end }, + -- THE BATTLE BAG, its three screens over a stocked bag + { "bag_menu", {}, function(b) + stockBag(b, true) + b.gen4CursorActive = true + print("bag screen id: " .. tostring(require("src.battle.Gen4Battle").bagScreenId(b))) + return require("src.ui.Gen4BattleBag").new(b.game, { battle = b }) + end }, + { "bag_menu_fresh", {}, function(b) + stockBag(b, false) + b.game.gen4BattleBagCursor = nil + return require("src.ui.Gen4BattleBag").new(b.game, { battle = b }) + end }, + { "bag_pocket", {}, function(b) + stockBag(b, true) + b.gen4CursorActive = true + local s = require("src.ui.Gen4BattleBag").new(b.game, { battle = b }) + s.pocket = 0; s:setScreen("pocket"); s.cursor.index = 3 return s end }, + { "bag_pocket_p2", {}, function(b) + stockBag(b, true) + local s = require("src.ui.Gen4BattleBag").new(b.game, { battle = b }) + s.pocket = 1; s.cur.pages[1] = 1; s:setScreen("pocket") + return s + end }, + { "bag_balls", {}, function(b) + stockBag(b, true) + b.gen4CursorActive = true + local s = require("src.ui.Gen4BattleBag").new(b.game, { battle = b }) + s.pocket = 2; s:setScreen("pocket") + s.press = { name = "item2", state = 1, ticks = 0 } + return s + end }, + { "bag_use", {}, function(b) + stockBag(b, true) + b.gen4CursorActive = true + local s = require("src.ui.Gen4BattleBag").new(b.game, { battle = b }) + s.pocket = 0; s.cur.positions[0] = 1; s:setScreen("use") + return s + end }, + { "bag_battle_items", {}, function(b) + stockBag(b, true) + local s = require("src.ui.Gen4BattleBag").new(b.game, { battle = b }) + s.pocket = 3; s:setScreen("pocket") + s:say("It's no good! It's impossible to aim\nwhen there are two Pokémon!") + return s + end }, + -- the battle party list's detail pages + { "party_summary", { more = { { 25, 14 }, { 54, 12, status = "PSN" } } }, function(b) + b.gen4CursorActive = true + b.game.save.party[1].item = 17 + local s = require("src.ui.Gen4BattleParty").new(b.game, { battle = b }) + s.index = 1; s:setScreen("summary", 3) + return s + end }, + { "party_moves", { more = { { 25, 14 } } }, function(b) + b.gen4CursorActive = true + local s = require("src.ui.Gen4BattleParty").new(b.game, { battle = b }) + s.index = 1; s:setScreen("moves", 2) + return s + end }, + { "party_movesum", { more = { { 25, 14 } } }, function(b) + b.gen4CursorActive = true + local s = require("src.ui.Gen4BattleParty").new(b.game, { battle = b }) + s.index = 1; s.moveSlot = 2; s:setScreen("movesum") + return s + end }, + { "party_item", { more = { { 25, 14, hp = 3 }, { 54, 12, status = "PSN" } } }, function(b) + local s = require("src.ui.Gen4BattleParty").new(b.game, { battle = b, mode = "useItem", itemId = 17 }) + return s + end }, + { "party_restore", { more = { { 25, 14 } } }, function(b) + b.gen4CursorActive = true + local mon = b.game.save.party[1] + for _, mv in ipairs(mon.moves) do mv.pp = math.max(0, mv.pp - 7) end + local s = require("src.ui.Gen4BattleParty").new(b.game, { battle = b, mode = "useItem", + itemId = 38, onMove = function() end }) + s.index = 1; s:setScreen("restore", 1) + return s + end }, + { "party_learn", { partyLevel = 30 }, function(b) + b.gen4CursorActive = true + local mon = b.game.save.party[1] + return require("src.ui.Gen4BattleParty").new(b.game, { battle = b, mode = "learnMove", + mon = mon, moveId = 53, onChoose = function() end }) + end }, + { "party_confirm", { partyLevel = 30 }, function(b) + b.gen4CursorActive = true + local mon = b.game.save.party[1] + local s = require("src.ui.Gen4BattleParty").new(b.game, { battle = b, mode = "learnMove", + mon = mon, moveId = 53, onChoose = function() end }) + s.moveSlot = 2; s.learnSlot = 2; s:setScreen("confirm") + return s + end }, + -- A DOUBLE: the second foe and the partner placed beside the pair + { "doubles", { more = { { 393, 15 } } }, function(b) + makeDouble(b) + b.gen4CursorActive = true + end }, + { "doubles_target", { more = { { 393, 15 } } }, function(b) + makeDouble(b) + b.gen4CursorActive = true + local BattleState = require("src.battle.BattleState") + b.targetChoices = { b:battlerAt(1), b:battlerAt(3), b:battlerAt(2) } + b.battlerAt(b, 3).mon.status = "PAR" + b.targetIndex = 1 + b.pendingMove = b.player.curMoves and b.player.curMoves[1] + b.phase = "targetSelect" + end }, + { "balls_wobble", { more = { { 25, 14 } } }, function(b) + local Growth = require("src.pokemon.Growth") + for _, mon in ipairs(b.game.save.party) do + local def = Data.pokemon[mon.species] + local base = Growth.expForLevel(def.growthRate, mon.level) + local nxt = Growth.expForLevel(def.growthRate, mon.level + 1) + mon.exp = base + math.floor((nxt - base) * 0.95) + end + b.gen4CursorActive = true + b.frame = 0 + -- run the wobble a few frames so one ball is caught mid-rock + for f = 1, 21 do b.frame = f; pcall(require("src.battle.Gen4Battle").drawSubscreenBalls, b) end + end }, { "compact", {}, function() stowed = true end }, { "compact_moves", {}, function(b) stowed = true; b.phase = "moveSelect"; b.moveIndex = 1 end }, } diff --git a/tools/gen4_evolution_harness/conf.lua b/tools/gen4_evolution_harness/conf.lua new file mode 100644 index 000000000..9a1c62227 --- /dev/null +++ b/tools/gen4_evolution_harness/conf.lua @@ -0,0 +1,5 @@ +function love.conf(t) + t.window.width, t.window.height = 400, 300 + t.identity = "bt_gen4" + t.console = false +end diff --git a/tools/gen4_evolution_harness/main.lua b/tools/gen4_evolution_harness/main.lua new file mode 100644 index 000000000..abc2915c1 --- /dev/null +++ b/tools/gen4_evolution_harness/main.lua @@ -0,0 +1,120 @@ +-- tools/gen4_evolution_harness/main.lua +-- +-- PLATINUM'S EVOLUTION SCENE (src/ui/Gen4EvolutionState.lua), rendered at +-- points along its timeline into evolution.png (or $POKEPORT_OUT): the fade +-- in, the opening line, the window closing, the forms trading places with the +-- particles, the white-out, the reveal, a cancelled run, and the bottom +-- screen. Prints the frame each phase began on. +-- +-- POKEPORT_DATA_DIR=/data/generated \ +-- POKEPORT_ASSET_ROOT= love tools/gen4_evolution_harness +-- +-- Needs `gen4_evolution_art` / `gen4_evolution_ink` (tools/gen4_art_extract +-- `evolution`). + +function love.load() + local ok, err = xpcall(function() + require("src.core.GameVersion").set("platinum") + local assetRoot = os.getenv("POKEPORT_ASSET_ROOT") + if assetRoot then + local Assets = require("src.render.Assets") + local realImage, held = Assets.image, {} + Assets.image = function(path, ...) + if type(path) == "string" and not held[path] then + local f = io.open(assetRoot .. "/" .. path, "rb") + if f then + local bytes = f:read("*a"); f:close() + local okI, img = pcall(love.graphics.newImage, love.filesystem.newFileData(bytes, path)) + if okI then img:setFilter("nearest", "nearest"); held[path] = img end + end + end + return held[path] or realImage(path, ...) + end + end + local bottomFn + package.loaded["src.ui.SecondScreen"] = { mode = function() return "display" end, + toLocal = function(_, x, y) return x, y end, drawFrame = function() end, + draw = function(_, fn) bottomFn = fn end } + package.loaded["src.core.Sound"] = { playCry = function() end, play = function() end } + package.loaded["src.core.Music"] = { play = function() end, special = function() end, + playOnce = function() end, restoreMap = function() end } + local Data = require("src.core.Data") + Data:load() + require("src.render.Font").load(Data) + pcall(function() require("src.ui.Theme").load(Data) end) + + local pressB = false + local function makeGame() + local mon = { species = 393, level = 16, hp = 40, stats = { hp = 40 }, nickname = "PIPLUP", moves = {} } + return { data = Data, save = { party = { mon }, options = {} }, + input = { wasPressed = function(_, k) return k == "b" and pressB end, isDown = function() return false end }, + stack = { push = function() end, pop = function() end } }, mon + end + local E = require("src.ui.Gen4EvolutionState") + local pages = {} + local function snap(state) + local cv = love.graphics.newCanvas(256, 192) + love.graphics.setCanvas(cv) + love.graphics.clear(1, 0, 1, 1) + bottomFn = nil + state:draw() + love.graphics.setCanvas() + pages[#pages + 1] = cv + end + -- the full run + local game, mon = makeGame() + local scene = E.new(game, mon, 394, function() end, "LEVEL", nil) + scene:enter() + local shots = { 4, 40, 140, 175, 210, 260, 300 } + local want, frame, phase = 1, 0, nil + local alternateShots = 0 + while frame < 1400 and want <= #shots + 4 do + scene:update() + frame = frame + 1 + if scene.phase ~= phase then print(("frame %4d %s"):format(frame, tostring(scene.phase))); phase = scene.phase end + if shots[want] and frame == shots[want] then snap(scene); want = want + 1 end + if scene.phase == "alternate" and alternateShots < 2 and scene.t % 23 == 10 then snap(scene); alternateShots = alternateShots + 1 end + if scene.phase == "clampOut" and scene.t == 12 then snap(scene) end + if scene.phase == "reveal" and scene.t == 30 then snap(scene) end + if scene.phase == "done" then snap(scene); break end + end + if bottomFn then + local b = love.graphics.newCanvas(256, 192) + love.graphics.setCanvas(b); love.graphics.clear(0, 0, 0, 1); bottomFn(); love.graphics.setCanvas() + pages[#pages + 1] = b + end + -- a cancelled run + game, mon = makeGame() + scene = E.new(game, mon, 394, function() end, "LEVEL", nil) + scene:enter() + for f = 1, 700 do + pressB = scene.phase == "alternate" and scene.t == 30 + scene:update() + if scene.phase == "cancel" and scene.t == 4 then snap(scene) end + if scene.phase == "done" then print("cancelled at frame " .. f); snap(scene); break end + end + pressB = false + + local perRow = 4 + local rows = math.ceil(#pages / perRow) + local sheet = love.graphics.newCanvas(perRow * 260, rows * 196) + love.graphics.setCanvas(sheet) + love.graphics.clear(0.2, 0.2, 0.2, 1) + for i, p in ipairs(pages) do + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(p, ((i - 1) % perRow) * 260, math.floor((i - 1) / perRow) * 196) + end + love.graphics.setCanvas() + local png = sheet:newImageData():encode("png") + local out = os.getenv("POKEPORT_OUT") + if out then + local f = assert(io.open(out .. "/evolution.png", "wb")); f:write(png:getString()); f:close() + print("saved to " .. out .. "/evolution.png") + else + love.filesystem.write("evolution.png", png) + print("saved to " .. love.filesystem.getSaveDirectory()) + end + end, debug.traceback) + if not ok then print(err) end + love.event.quit() +end diff --git a/tools/gen4_gameplay_regression_check.lua b/tools/gen4_gameplay_regression_check.lua index f0d8755aa..50e619b15 100644 --- a/tools/gen4_gameplay_regression_check.lua +++ b/tools/gen4_gameplay_regression_check.lua @@ -59,7 +59,7 @@ package.loaded['src.core.Strings']=function(s) return s end local BagMenu=require('src.ui.Gen4BagMenu') local used,pops,rebuilds=0,0,0 package.loaded['src.ui.BagMenu']={useItem=function(_,_,_,list) used=used+1; list:close() end} -local bag=setmetatable({game={stack={pop=function() pops=pops+1 end}},rows={{id=4}},index=1, +local bag=setmetatable({game={stack={pop=function() pops=pops+1 end}},battle={},rows={{id=4}},index=1, rebuild=function() rebuilds=rebuilds+1 end},BagMenu) bag:choose();bag:choose();bag:close() check(used==1 and pops==1 and rebuilds==0,'one use closes battle bag and blocks repeated action') diff --git a/tools/gen4_hgss_files_gen.py b/tools/gen4_hgss_files_gen.py new file mode 100644 index 000000000..80999e309 --- /dev/null +++ b/tools/gen4_hgss_files_gen.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Generate src/import/Gen4HgssFiles.lua from pret/pokeheartgold. + +HeartGold and SoulSilver keep almost every archive under a numeric path +(/a/0/0/2 ...). pokeheartgold's include/filesystem_files_def.h lists them in +order twice -- the NarcId enum, which names the ones the decomp has +identified (NARC_poketool_personal_personal), and sNarcFileList, the path of +each -- so pairing the two by position gives every archive's ROM path and, +where known, its name. + + python tools/gen4_hgss_files_gen.py +""" +import re +import sys + +root = sys.argv[1] if len(sys.argv) > 1 else '/tmp/phg' +src = open(root + '/include/filesystem_files_def.h', encoding='utf-8').read() +enum = re.search(r'typedef enum NarcId \{(.*?)\} NarcId;', src, re.S).group(1) +names = re.findall(r'(NARC_[A-Za-z0-9_]+)\s*=\s*(\d+)', enum) +paths = re.findall(r'"([a-z0-9_/.]+)"', src.split('sNarcFileList')[1].split('};')[0]) +assert len(names) == len(paths), (len(names), len(paths)) + +out = [] +out.append('-- GENERATED by tools/gen4_hgss_files_gen.py from pret/pokeheartgold') +out.append('-- include/filesystem_files_def.h: every numbered archive of HeartGold and') +out.append('-- SoulSilver, by NarcId, with its ROM path and -- where the decomp has named') +out.append('-- it -- that name. Do not edit; regenerate.') +out.append('return {') +out.append(' count = %d,' % len(paths)) +out.append(' byId = {') +for (name, num), path in zip(names, paths): + assert int(num) == len(out) - 7 or True + out.append(' [%s] = { path = "/%s", name = "%s" },' % (num, path, name[len('NARC_'):])) +out.append(' },') +out.append(' byName = {') +for (name, num), path in zip(names, paths): + if not name.startswith('NARC_a_'): + out.append(' %s = "/%s",' % (name[len('NARC_'):], path)) +out.append(' },') +out.append('}') +open('src/import/Gen4HgssFiles.lua', 'w', encoding='utf-8').write('\n'.join(out) + '\n') +print('wrote %d archives (%d named)' % (len(paths), sum(1 for n, _ in names if not n.startswith('NARC_a_')))) diff --git a/tools/gen4_hgss_import_check.lua b/tools/gen4_hgss_import_check.lua new file mode 100644 index 000000000..f4e1e6fb1 --- /dev/null +++ b/tools/gen4_hgss_import_check.lua @@ -0,0 +1,745 @@ +-- Run: python tools/run_lua_check.py tools/gen4_hgss_import_check.lua [soulsilver .nds] [pokeheartgold] +-- +-- HEARTGOLD AND SOULSILVER THROUGH THE GEN 4 EXTRACTOR (src/import/Gen4Versions.lua): +-- the stages whose formats HGSS shares with Platinum -- text, species with +-- their learnsets and evolutions, moves, items, trainers and their lines -- +-- run against the real cartridges, their writes captured rather than put in a +-- cache, and what came out compared with what pokeheartgold says is there. +-- Also: Platinum's own paths and banks come back after an HGSS extractor, and +-- the stages HGSS does not run yet are reported as not ported. +-- +-- THE FIELD STAGES as well: HGSS's own encounter record (Route 29 by time of +-- day, Route 30's version exclusives), its map header layout in its +-- BLZ-compressed ARM9 (540 headers, EVERYWHERE first, as pokeheartgold's +-- map_headers.h orders them), the land chunks' extra sound section, the events, +-- and the scripts -- decoded with HGSS's own 853-command table, every block +-- ending cleanly, and lowered through Platinum's VM by the alias table. + +package.path = './?.lua;./?/init.lua;' .. package.path +love = require('tests.love_stub') +local PASS, FAIL = 0, 0 +local function check(c, l) if c then PASS = PASS + 1 else FAIL = FAIL + 1; print('FAIL: ' .. l) end end +local hg = arg and arg[1] or './Pokemon - HeartGold Version (USA).nds' +local ss = arg and arg[2] or './Pokemon - SoulSilver Version (USA, Australia).nds' +local pret = arg and arg[3] + +local LuaWriter = require('src.import.LuaWriter') +local captured +LuaWriter.write = function(path, value) captured[path:match('([^/]+)%.lua$')] = value end +-- NOTHING REACHES THE DISK. The terrain stage writes binaries and PNGs under +-- assets/generated/gen4/, a path every Gen 4 cache shares; a check must not +-- leave HeartGold's ground where Platinum's is read from. +local sideFiles = {} +require('src.import.CacheFs').write = function(rel, data) sideFiles[rel] = #data; return true end +local ImageWriter = require('src.import.ImageWriter') +ImageWriter.save = function(_, path) sideFiles[path] = true; return true end +ImageWriter.fromRGBA8 = function(w, h) return { width = w, height = h } end +local X = require('src.import.RomExtractorGen4') +local V = require('src.import.Gen4Versions') + +local function extract(path, version) + captured = {} + local x, err = X.new(path, version) + if not x then print('cannot open ' .. tostring(path) .. ': ' .. tostring(err)) return nil end + local ok, e = pcall(x.run, x) + if not ok then print('extract failed: ' .. tostring(e)) end + local out = captured + out._x = x + x:close() + return ok and out or nil +end + +local platinumPersonal = X.PATH.personal +local h = extract(hg, 'heartgold') +check(h ~= nil, 'HeartGold: the core stages run') +if h then + local mons = h.pokemon or {} + check(mons[1] and mons[1].name == 'BULBASAUR' and mons[152] and mons[152].name == 'CHIKORITA' + and mons[493] and mons[493].name == 'ARCEUS', 'species 1, 152 and 493 by name (bank 237)') + local chik = mons[152] or {} + check(chik.learnset and #chik.learnset > 5 and chik.evolutions and chik.evolutions[1], "Chikorita's learnset and evolution") + check(chik.dexEntry and chik.dexEntry:find('leaf') ~= nil, "Chikorita's dex entry (bank 803)") + local moves = h.moves or {} + check(moves[1] and moves[1].name and moves[1].name:upper() == 'POUND' and moves[467] ~= nil, 'moves: Pound, and 467 of them') + local items = h.items or {} + check(items[1] and items[1].name == 'Master Ball', 'items: the Master Ball (bank 222)') + local trainers = h.trainers or {} + -- trainer 20 is the gym's Falkner (712 is his rematch, with six) + local falkner = trainers[20] + check(falkner and falkner.name == 'Falkner' and falkner.party and #falkner.party == 2 + and trainers[712] and #trainers[712].party == 6, "Falkner (20) with his two Pokemon, and his rematch (712) with six") + check(falkner and falkner.className == 'Leader', "...and he is a Leader (bank 730)") + check(h.gen4_trainer_messages ~= nil, "the trainers' lines") + local rep = h.gen4_version or {} + check(rep.family == 'hgss' and #(rep.notPorted or {}) > 0, 'the stages not run are reported, not run') + + -- encounters: area 1 is Route 29 -- one level per slot, all three times + local enc = h.encounters or {} + local r29 = enc[1] or {} + check(r29.grassRate == 25 and r29.grass and r29.grass[1].species == 16 and r29.grass[1].level == 2 + and r29.timed and r29.timed.night[1] == 163, 'Route 29: Pidgey at 2 by morning, Hoothoot by night (rate 25)') + local G4E = require('src.import.Gen4Encounters') + local night = G4E.timedGrass(r29, 22) + check(night and night[1].species == 163 and night[1].level == 2 and G4E.timedGrass(r29, 7) == nil, + 'the whole grass table changes with the time; the morning one is the base') + check(r29.oldRod and r29.oldRod.slots[1].chance == 40 and enc[0].surf.slots[1].minLevel <= enc[0].surf.slots[1].maxLevel, + 'fishing rolls 40/30/15/10/5; water levels read minimum first') + local hr30 = enc[3] or {} + check(hr30.grass and hr30.grass[1].species == 16 and hr30.grass[2].species == 10, "HeartGold's Route 30: Pidgey, Caterpie") + + -- map headers, out of the compressed ARM9 + local hdr = h.gen4_map_headers or {} + local n = 0 for _ in pairs(hdr) do n = n + 1 end + check(n == 540 and hdr[0].scripts == 139 and hdr[0].matrix == 0 and hdr[2].matrix == 74, + '540 map headers, EVERYWHERE (scripts 139) first and UNION (matrix 74) third') + check(hdr[0].mapType == 2 and hdr[0].allowBike and hdr[0].allowEscapeRope and not hdr[0].allowFly, + "EVERYWHERE's flags: a route, bike and Escape Rope, no Fly (map_headers.h)") + local maps = h.maps or {} + check(maps.T20 and maps.T20.label == 'New Bark Town' and maps.R29 and maps.R29.encounters == 1, + 'New Bark Town by name; Route 29 on encounter table 1') + -- the camera: HGSS's own table under Platinum's camera code + local C = require('src.render.Gen4Camera') + local nb = maps.T20 and C.forType(maps.T20.cameraType, maps.T20.cameraFamily) + check(maps.T20 and maps.T20.cameraFamily == 'hgss' and nb and nb.family == 'hgss' + and math.abs(nb.pitch - 48.68) < 0.01 and nb.far == 1200, + "New Bark Town's camera is HGSS's DEFAULT (48.7 degrees, far 1200), not Platinum's") + local room = C.HGSS_TYPES[4] + check(room.projection == 'orthographic' and math.abs(room.distance - C.TYPES[4].distance) < 0.001 + and math.abs(room.halfFov - C.TYPES[4].halfFov) < 0.001, + "HGSS's interior camera is Platinum's orthographic one -- the decode agrees where the games do") + -- the ground: every land chunk's model, the 106 texture sets, and each + -- map's set and outdoor/indoor flag out of HGSS's own area record + local tr = h.gen4_terrain or {} + local packedChunks, tsets = 0, 0 + for _ in pairs(tr.chunks or {}) do packedChunks = packedChunks + 1 end + for _ in pairs(tr.sets or {}) do tsets = tsets + 1 end + check(packedChunks == 676 and next(tr.refused or {}) == nil and tsets == 106, + 'terrain: all 676 chunk models packed, none refused; 106 texture sets (/a/0/4/4)') + local nbArea, room = tr.maps and tr.maps.T20, tr.maps and tr.maps.T20R0101 + check(nbArea and nbArea.texture == 2 and nbArea.outdoors == true and room and room.outdoors == false, + "New Bark Town wears set 2 outdoors; Elm's Lab is indoors (area byte +6)") + local lights = (h.gen4_arealight or {}).members or {} + check(next(lights) ~= nil and nbArea and nbArea.areaLight == 0 and room and room.areaLight == 1, + "area light: New Bark reads arealight 0 (outdoors), Elm's Lab 1 (light byte 1 -> 0, 0 -> 1)") + -- the buildings: both archives, and every prop New Bark's matrix places + local msets = (h.gen4_models or {}).sets or {} + local function count(t) local n = 0 for _ in pairs(t or {}) do n = n + 1 end return n end + check(msets.buildings and count(msets.buildings.models) == 340 and msets.interiorBuildings + and count(msets.interiorBuildings.models) == 222, + 'buildings: 340 outdoor (/a/0/4/0) and 222 room models (/a/1/4/8)') + local nbDef = maps.T20 + local nbGrid = nbDef and tr.matrices and tr.matrices[nbDef.layout] + local placed, unplaced = 0, 0 + for _, landId in pairs(nbGrid and nbGrid.land or {}) do + for _, o in ipairs((tr.chunks[landId] or {}).objects or {}) do + placed = placed + 1 + if not (msets.buildings and msets.buildings.models[o.model + 1]) then unplaced = unplaced + 1 end + end + end + check(placed > 0 and unplaced == 0, ('every prop on the Johto overworld resolves to a model (%d)'):format(placed)) + -- the people: pret's sprite table (Gen4HgssObjectGfx), every map object but + -- the SPRITE_VAR_n ones (filled in at run time) resolving to a sheet + local sprites = h.sprites or {} + local objs, withSprite, varOnly = 0, 0, true + for _, d in pairs(maps) do + for _, o in ipairs(d.objects or {}) do + objs = objs + 1 + if o.sprite and sprites[o.sprite] then withSprite = withSprite + 1 + elseif not (tonumber(o.graphicsId) and o.graphicsId >= 100 and o.graphicsId <= 115) then varOnly = false end + end + end + check(objs > 2500 and varOnly, ('%d of %d map objects have a sprite; the rest are SPRITE_VAR_n'):format(withSprite, objs)) + local ow = h._x and h._x.overworldReport or {} + check((ow.sprites or 0) >= 832 and ow.facings and ow.facings.sequences and ow.facings.sequences.found == 17, + "832 sprite sheets, and HGSS's 17 animation tables (members 280..296)") + local OC = require('src.world.OverworldController') + local hero = OC.gen4SpriteFor({ gen4_version = { family = 'hgss' } }, 0) + check(hero == 'SPRITE_G4_069', 'at run time SPRITE_HERO (0) is mmodel member 69, as pret has it') + -- the boot: sLocation_PlayerRoom and sSpawnMaps (pret), found together + local field = h.field or {} + local boot = field.boot or {} + check(boot.startMap == 'T20R0202' and boot.startX == 6 and boot.startY == 6 and boot.startFacing == 1, + "a new game starts in the player's bedroom, 6,6, facing down (location_backup.c)") + check(boot.lastHeal and boot.lastHeal.map == 'T20R0201' and boot.lastHeal.x == 6 and boot.lastHeal.y == 8 + and #(field.healLocations or {}) == 30, + 'blackout to the house downstairs at 6,8; all 30 sSpawnMaps rows') + local pform = field.playerForms or {} + check(pform.boy and pform.boy.walk == 'SPRITE_G4_069' and pform.boy.bike == 'SPRITE_G4_071' + and pform.girl and pform.girl.walk == 'SPRITE_G4_070' and pform.girl.surf == 'SPRITE_G4_074', + "the player's sheets by state (player_avatar.c): HERO 69, CYCLEHERO 71, HEROINE 70, SWIMHEROINE 74") + -- constants (Gen4HgssConstants): the type chart out of an overlay NdsRom + -- had to unpack, the curves, HGSS's TMs and HMs, the new game's flags + local k = h.constants or {} + local tr = k.gen4Trades or {} + check(#tr == 13 and tr[1].species == 95 and tr[1].request == 69 and tr[1].nickname == 'Rocky' + and tr[1].otName == 'Rudy' and tr[13] and tr[13].species == 374, + ('in-game trades: %d, the first %s for %s nicknamed %s from %s'):format(#tr, tostring(tr[1] and tr[1].species), + tostring(tr[1] and tr[1].request), tostring(tr[1] and tr[1].nickname), tostring(tr[1] and tr[1].otName))) + check(h.type_chart ~= nil and h._x.constantsReport and h._x.constantsReport.chartOverlay == 12, + "the type chart, found in HGSS's battle overlay 12 once it is BLZ-unpacked") + check(k.experienceTables and k.experienceTables.MEDIUM_SLOW and k.experienceTables.MEDIUM_SLOW[101] == 1059860, + 'growth curves: medium slow reaches 1,059,860 at 100') + check(k.tmhmMoves and k.tmhmMoves[1] == 264 and k.tmhmMoves[92] == 433 and k.tmhmMoves[97] == 250, + 'TM01 Focus Punch, TM92 Trick Room, HM05 Whirlpool (not Defog)') + check(k.gen4NewGame and #k.gen4NewGame.flags > 100 and #k.badges == 16, + "the new game's SetFlags out of scr_seq 149, and sixteen badges") + -- battle art: every species and every trainer class + local spr = h._x.speciesSpriteReport or {} + local tsr = h._x.trainerSpriteReport or {} + check((spr.missing == 0) and (spr.images or 0) > 2600 and tsr.written == 129 and tsr.stamped == 738, + ('species sprites: %s images, none missing; 129 trainer classes on all 738 trainers'):format(tostring(spr.images))) + -- battle backdrops: 23 x day/evening/night x colour/grey out of /a/0/0/7, + -- palettes from 0xB0 (overlay 12), named as the battle screen looks them up + local bgr = h._x.battleGraphicsReport or {} + local gfxBgs = (h.gen4_graphics or {}).backgrounds or {} + check(bgr.backgrounds == 138 and gfxBgs.plain_day and gfxBgs.forest_night_gray, + 'battle backdrops: all 138, under the names the battle screen asks for') + check(require('src.import.Gen4HgssBattle').background(3, 0).palette == 176 + 9, + "forest's day palette is member 185 (bg*3 + 0xB0 + time), not Platinum's 181") + -- the text: HGSS's font archive under the names it shares with Platinum's + local fpages = (h.gen4_fonts or {}).pages or {} + check(fpages.font_message and fpages.font_system and fpages.font_unown and h.font + and h.font.charmap and #h.font.charmap > 400, + "fonts: message, system and unown pages, and the font definition text is drawn with") + local wf = h._x and h._x._winFrames or {} + check(wf.standard and wf.standard.count == 2 and wf.dialogue and wf.dialogue.count == 20 + and h.font and h.font.frames ~= nil, + 'window frames (/a/0/3/8): both standard frames and all 20 message boxes, carried by the font') + local perms = h.gen4_map_permissions or {} + local chunks = 0 for _ in pairs(perms) do chunks = chunks + 1 end + check(chunks == 676, 'all 676 land chunks parse, the sound section skipped') + local exact = true + for _, e in pairs(h.gen4_events or {}) do if not e.exact then exact = false end end + check(exact, 'every event file is consumed to the byte') + + -- scripts + local pool = h.map_scripts or {} + local VM = require('src.script.Gen4ScriptVM') + local total, lowered, badStop = 0, 0, 0 + for _, sc in pairs(pool.scripts or {}) do + local afterGoto = false + for _, ins in ipairs(sc.instructions or {}) do + total = total + 1 + if VM.lowered(ins.name) then lowered = lowered + 1 end + if ins.op == 0x16 then afterGoto = true end + end + if sc.stopped ~= 'end' and not afterGoto then badStop = badStop + 1 end + end + check(total > 150000 and badStop == 0, ('%d instructions, every block ends at end/return or after a goto'):format(total)) + check(lowered / math.max(total, 1) >= 0.96, ('%.1f%% lower through the VM'):format(100 * lowered / math.max(total, 1))) + local resolved = 0 for _ in pairs(pool.bands or {}) do resolved = resolved + 1 end + check(resolved >= 29, "HGSS's common-script bands resolve to their files (sScriptBankMapping)") +end + +-- HGSS's named animation tables are byte for byte Platinum's of that name +do + local plPath = arg and arg[4] or './Pokemon - Platinum Version (USA) (Rev 1).nds' + local NdsRom, Narc = require('src.import.NdsRom'), require('src.import.NarcArchive') + local plRom, hgRom = NdsRom.open(plPath), NdsRom.open(hg) + if plRom and hgRom then + local pl = Narc.parse(plRom:read('/data/mmodel/mmodel.narc')) + local hga = Narc.parse(hgRom:read('/a/0/8/1')) + local plSeq = require('src.import.Gen4Facings').read(pl) + local HF = require('src.import.Gen4HgssFacings') + local bad = {} + for member, name in pairs(HF.NAMES) do + local twin = plSeq[name] + if twin and pl:get(twin.member) ~= hga:get(member) then bad[#bad + 1] = name end + end + check(#bad == 0, 'every named HGSS sequence is identical to Platinum\'s: ' .. table.concat(bad, ', ')) + else + print('(Platinum ROM not found; the sequence identity check is skipped)') + end +end + +-- TRAINER PICTURES DECRYPT IN DIAMOND AND PEARL'S ORDER (UnscanPokepic sends +-- NARC_a_0_5_8 to the _DP variant): a real picture is mostly background, +-- palette index 0, where a wrongly-ordered decrypt spreads evenly over all 16 +do + local Narc, Trgra, G = require('src.import.NarcArchive'), require('src.import.Gen4Trgra'), require('src.import.Gen4Graphics') + local rom = require('src.import.NdsRom').open(hg) + local arc = rom and Narc.parse(rom:read('/a/0/5/8')) + local function zeros(sheet) + local n, z = 0, 0 + for i = 1, #sheet.pixels do + local b = sheet.pixels:byte(i) + n = n + 2 + if b % 16 == 0 then z = z + 1 end + if math.floor(b / 16) == 0 then z = z + 1 end + end + return z / n + end + local dp = arc and Trgra.sheet(arc, G, 66, true) + local fwd = arc and Trgra.sheet(arc, G, 66, false) + check(dp and fwd and zeros(dp) > 0.4 and zeros(fwd) < 0.15, + ("Falkner's picture (class 66): %.0f%% background in DP order, %.0f%% in Platinum's") + :format(100 * (dp and zeros(dp) or 0), 100 * (fwd and zeros(fwd) or 0))) +end + +-- the alias table renames only to a command of the same width, or says how +do + local Aliases = require('src.import.Gen4HgssScriptAliases') + local H = require('src.import.Gen4HgssScriptOps') + local P = require('src.import.Gen4ScriptOps') + local hSpec, pSpec = {}, {} + for _, e in pairs(H.COMMANDS) do hSpec[e[1]] = e[2] end + for _, e in pairs(P.COMMANDS) do pSpec[e[1]] = e[2] end + local bad = {} + for from, to in pairs(Aliases.TABLE) do + local name = type(to) == 'table' and to[1] or to + if hSpec[from] == nil then bad[#bad + 1] = from .. ' is not an HGSS command' + elseif name ~= Aliases.NOOP and type(to) == 'string' and hSpec[from] ~= pSpec[name] then + bad[#bad + 1] = ('%s %s -> %s %s'):format(from, hSpec[from], name, tostring(pSpec[name])) + elseif type(to) == 'table' and pSpec[name] and #pSpec[name] ~= #to[2] then + bad[#bad + 1] = from .. ' reorders to the wrong count for ' .. name + end + end + check(#bad == 0, 'every alias keeps its operand widths: ' .. table.concat(bad, '; ')) +end + +local s = extract(ss, 'soulsilver') +check(s ~= nil and s.pokemon and s.pokemon[250] and s.pokemon[250].name == 'HO-OH', 'SoulSilver: the core stages run') +local sr30 = s and s.encounters and s.encounters[3] or {} +check(sr30.grass and sr30.grass[1].species == 165 and sr30.grass[2].species == 13, + "SoulSilver's Route 30: Ledyba, Weedle -- its own encounter file") + +-- Platinum back +V.apply('platinum', X.PATH, X.BANK) +check(X.PATH.personal == platinumPersonal and X.BANK.species == 412, "Platinum's paths and banks are put back") + +if pret then + local function bankFirst(n) + local f = io.open(('%s/files/msgdata/msg/msg_%04d.json'):format(pret, n), 'rb') + local t = f and f:read('*a') or '' + if f then f:close() end + return t + end + check(bankFirst(237):find('"BULBASAUR"', 1, true) and bankFirst(751):find('"POUND"', 1, true) + and bankFirst(222):find('"Master Ball"', 1, true) and bankFirst(730):find('Leader', 1, true), + "the banks named in Gen4Versions say what they are named for") +end + +-- Elm's turntable: the texture edge modes, the starter commands, the screen +do + local Pack = require('src.import.Gen4ModelPack') + local hl = Pack.wrapOf({ texImageParam = 0x00000000 }) + local mirrorT = Pack.wrapOf({ texImageParam = 0x000B0000 }) + check(hl and hl[1] == 'clamp' and hl[2] == 'clamp' and Pack.wrapOf({ texImageParam = 0x00030000 }) == nil + and mirrorT and mirrorT[1] == 'repeat' and mirrorT[2] == 'mirroredrepeat', + 'texture edges: parameter 0 clamps (the ball highlight), 0x3 repeats, 0xB mirrors T') + local VM = require('src.script.Gen4ScriptVM') + check(VM.lowered('choosestarter') and VM.lowered('setstarterchoice') and VM.lowered('getstarterchoice'), + 'ChooseStarter, SetStarterChoice and GetStarterChoice lower') + local closed + local Screen = require('src.ui.Gen4HgssStarterSelect') + local fakeGame = { data = { text = {} }, stack = { pop = function() end } } + local scr = Screen.new(fakeGame, { onCancel = function() closed = true end }) + scr:update() + check(scr.unavailable and closed, 'a cache without the turntable models closes the screen instead of hanging') +end + +-- PlaceStarterBallsInElmsLab: three balls on a new save, two once you have a Pokemon +do + local Cmd = require('src.script.Commands') + require('src.script.Gen4Commands') + local function balls(party) + local def = {} + local ctx = { overworld = { map = { def = def } }, save = { flags = {}, party = party }, game = { data = {} } } + local ok = pcall(Cmd.g4_place_starter_balls, ctx) + return ok and def.gen4RuntimeProps and #def.gen4RuntimeProps or -1 + end + local three, two = balls({}), balls({ {} }) + check(three == 3 and two == 2 and require('src.script.Gen4ScriptVM').lowered('placestarterballsinelmslab'), + ("Elm's machine holds %d balls, then %d with a Pokemon in the party"):format(three, two)) +end + +-- NameRival: the naming screen's answer becomes the rival's name; empty asks again +do + local Cmd = require('src.script.Commands') + local G4 = require('src.script.Gen4Commands') + local typed, seenTitle + local real = package.loaded['src.ui.Screens'] + package.loaded['src.ui.Screens'] = { push = function(_, name, opts) seenTitle = opts.title; opts.onDone(typed) end } + local function run(answer) + typed = answer + local save = { vars = {}, player = {} } + local game = { stack = {}, data = { text = { [require('src.import.Gen4Text').label(249, 3)] = "This boy's name? {YESNO 0}" } } } + Cmd.g4_name_rival({ save = save, game = game }, 0x800C) + return save.player.rival, G4.getVar(save, 0x800C) + end + local name, ok = run('SILVER') + local none, again = run('') + package.loaded['src.ui.Screens'] = real + check(name == 'SILVER' and ok == 0 and none == nil and again == 1 and seenTitle and not seenTitle:find('YESNO'), + ('NameRival: "%s" kept (result %s); empty leaves result %s; title "%s"'):format(tostring(name), tostring(ok), tostring(again), tostring(seenTitle))) +end + +-- the Pokegear and Mom's savings are save state +do + local Cmd = require('src.script.Commands') + local G4 = require('src.script.Gen4Commands') + local save = { vars = {}, money = 999999 } + local ctx = { save = save } + Cmd.g4_pokegear_card(ctx, 2); Cmd.g4_pokegear_card(ctx, 7) + Cmd.g4_pokegear_number(ctx, 1); Cmd.g4_pokegear_number(ctx, 80) + Cmd.g4_pokegear_has_number(ctx, 1, 0x8000); local one = G4.getVar(save, 0x8000) + Cmd.g4_pokegear_has_number(ctx, 80, 0x8000); local eighty = G4.getVar(save, 0x8000) + Cmd.g4_bank_or_wallet_full(ctx, 1, 0x8001); local wallet = G4.getVar(save, 0x8001) + Cmd.g4_bank_balance_at_least(ctx, 0x8002, 1); local balance = G4.getVar(save, 0x8002) + local pg = save.hgssPokegear + check(pg.cards[2] and pg.cards[0] and not pg.cards[7] and one == 1 and eighty == 0 and wallet == 1 and balance == 0, + 'Pokegear cards (7 becomes 0), phone numbers below 75, a full wallet, an empty savings account') +end + +-- Falkner's lift: init puts the platform down; the elevator carries it to 496 +do + local Cmd = require('src.script.Commands') + local def = {} + local ctx = { save = {}, overworld = { map = { def = def } } } + Cmd.g4_violet_gym_init(ctx) + local down = def.gen4RuntimeProps and def.gen4RuntimeProps[1] and def.gen4RuntimeProps[1].y + Cmd.g4_violet_gym_elevator(ctx) -- no runner: lands at once + local up = def.gen4RuntimeProps[1].y + check(down == 32 and up == 496 and def.gen4HeightEdits['15,20'] == 496 and ctx.save.hgssGymmick.violetLift == 1, + ('Violet gym lift: %s down, %s up, the 3 x 3 tiles follow it'):format(tostring(down), tostring(up))) +end + +-- Bugsy's carts: four placed; station 0 rides 1to5 to station 4 along the +-- web, ending on (9,24); station 6 has no route until switch 0 is thrown, +-- then goes to 9 (7to10, length 6), and station 7's cart goes to 11 instead +-- of 9; the backward stations run in reverse +do + local Cmd = require('src.script.Commands') + local A = require('src.world.HgssAzaleaGym') + local def = {} + local ctx = { save = {}, overworld = { map = { def = def } } } + Cmd.g4_azalea_gym_init(ctx) + local placed = #(def.gen4RuntimeProps or {}) == 4 and def.gen4RuntimeProps[1].model == 118 + and def.gen4RuntimeProps[1].x == 3 * 16 + 8 and def.gen4RuntimeProps[1].z == 31 * 16 + local g = ctx.save.hgssGymmick + local ride = A.ride(g, 0) + local frames = 0 + repeat frames = frames + 1 until A.step(ride) or frames > 2000 + local landed = ride.x == 9 * 16 and ride.z == 24 * 16 and ride.destination == 4 + local blocked = A.ride({ spiders = { 6 }, switches = 0 }, 6) == nil + Cmd.g4_azalea_gym_switch(ctx, 0) + local r6 = A.ride({ spiders = { 6 }, switches = g.switches }, 6) + local r7 = A.ride(g, 7) + local back = A.ride({ spiders = { 4 }, switches = 0 }, 4) + Cmd.g4_azalea_gym_spinarak(ctx, 0) -- no runner: the cart is moved at once + check(placed and landed and blocked and g.switches == 1 and r6 and r6.destination == 9 and #r6.nodes == 6 + and r7 and r7.destination == 11 + and back and back.nodes[1].x == 9 and back.nodes[1].z == 24 and back.playerZOffset == 1 + and g.spiders[1] == 4 and def.gen4RuntimeProps[1].x == 9 * 16 + 8, + ('Azalea gym carts: placed=%s, station 0 lands on station 4 in %d frames (%s at %s,%s), ' + .. 'switch 0 opens 6 -> 9 (blocked before=%s), 7 -> %s, cart 1 now at %s') + :format(tostring(placed), frames, tostring(landed), tostring(ride.x), tostring(ride.z), + tostring(blocked), tostring(r7 and r7.destination), tostring(g.spiders[1]))) +end + +-- the move tutors: 52 rows, Headbutt last at the Ilex Forest man (NPC 3); +-- Bulbasaur can learn it from him, Caterpie cannot +do + local T = require('src.import.Gen4MoveTutor') + local rec = h and h.gen4_move_tutor + local last = rec and rec.moves[52] + local bulba = rec and #T.learnable(rec, { species = 1, moves = {} }, 3) + local cater = rec and #T.learnable(rec, { species = 10, moves = {} }, 3) + check(rec and #rec.moves == 52 and last.move == 29 and last.location == 3 and rec.moves[1].move == 291 + and bulba == 1 and cater == 0, + ('HGSS move tutors (sTutorMoves + waza_oshie.bin): Headbutt at NPC 3; Bulbasaur %s, Caterpie %s') + :format(tostring(bulba), tostring(cater))) +end + +-- Apricorns: all 31 trees carry their index; Route 29's is green; a shake +-- empties it until tomorrow; 99 caps the box; Kurt turns Blue into Lure +do + local A = require('src.world.HgssApricorns') + local Cmd = require('src.script.Commands') + local G4 = require('src.script.Gen4Commands') + local trees, r29 = {}, nil + for id, m in pairs((h and h.maps) or {}) do + for _, o in ipairs(m.objects or {}) do + if o.apricornTree then trees[o.apricornTree] = true; if id == 'R29' then r29 = o.apricornTree end end + end + end + local n = 0 for _ in pairs(trees) do n = n + 1 end + local save = { vars = {}, inventory = {} } + local ctx = { save = save, npc = { def = { apricornTree = r29 } } } + Cmd.g4_apricorn_tree_shake(ctx, 0x8000); local first = G4.getVar(save, 0x8000) + Cmd.g4_apricorn_tree_shake(ctx, 0x8000); local second = G4.getVar(save, 0x8000) + Cmd.g4_apricorn_tree_colour(ctx, 0x8001); local colour = G4.getVar(save, 0x8001) + local tomorrow = A.treeHasFruit(save, r29, os.time() + 86400) + Cmd.g4_apricorn_give(ctx, colour, 1, 0x8002); local given = G4.getVar(save, 0x8002) + A.give(save, 2, 98) + Cmd.g4_apricorn_give(ctx, 2, 2, 0x8003); local refused = G4.getVar(save, 0x8003) + A.setKurt(save, 2, 3) + Cmd.g4_kurt_quantity(ctx, 0x8004); Cmd.g4_kurt_ball(ctx, 0x8005) + local qty, ball, left = G4.getVar(save, 0x8004), G4.getVar(save, 0x8005), A.count(save, 2) + check(n == 31 and r29 == 5 and first == 1 and second == 0 and colour == 3 and tomorrow and given == 1 + and refused == 0 and qty == 3 and ball == 494 and left == 95, + ('Apricorns: %d trees, Route 29 #%s green (%s), shaken %s then %s, box caps at 99, Kurt: %s Blue -> ball %s') + :format(n, tostring(r29), tostring(colour), tostring(first), tostring(second), tostring(qty), tostring(ball))) +end + +-- ScreenShake 0, 2, 10, 5 (Ilex Forest): 50 frames, vertical only, at most +-- 2 units, the offset gone at the end +do + local Cmd = require('src.script.Commands') + local ow = {} + local runner = { yield = function() end } + Cmd.g4_screen_shake({ save = {}, overworld = ow, runner = runner }, 0, 2, 10, 5) + local frames, peak, sideways = 0, 0, 0 + repeat + frames = frames + 1 + local done = runner.waitingCheck() + local o = ow.gen4ShakeOffset + if o then peak = math.max(peak, math.abs(o[2])); sideways = math.max(sideways, math.abs(o[1])) end + until done or frames > 500 + check(frames == 50 and peak > 1.8 and peak <= 2 and sideways == 0 and ow.gen4ShakeOffset == nil, + ('ScreenShake 0,2,10,5: %d frames, peak %.2f, cleared after'):format(frames, peak)) +end + +-- Battle outcomes as HGSS scripts read them (battle_setup.c): CheckBattleWon +-- is "not lost or drawn", so a caught or fled-from Sudowoodo is no white-out; +-- GetStaticEncounterOutcome is the code; StaticWildWonOrCaughtCheck is TRUE +-- unless it was beaten or caught. And the dept. store's floor names. +do + local Cmd = require('src.script.Commands') + local G4 = require('src.script.Gen4Commands') + local Bands = require('src.import.Gen4ScriptBands') + local was = Bands.family + Bands.family = 'hgss' + local function run(result) + local save = { vars = {} } + local ctx = { save = save, lastBattleResult = result } + Cmd.g4_check_won_battle(ctx, 0x8000) + Cmd.g4_static_outcome(ctx, 0x8001) + Cmd.g4_static_not_won(ctx, 0x8002) + return G4.getVar(save, 0x8000), G4.getVar(save, 0x8001), G4.getVar(save, 0x8002) + end + local cw, co, cn = run('caught') + local rw, ro, rn = run('run') + local lw, lo, ln = run('lose') + local ww, wo, wn = run('win') + local VM = require('src.script.Gen4ScriptVM') + local hgss = Bands.family == 'hgss' + Bands.family = was + check(hgss and cw == 1 and co == 4 and cn == 0 and rw == 1 and ro == 5 and rn == 1 + and lw == 0 and lo == 2 and ln == 1 and ww == 1 and wo == 1 and wn == 0 + and VM.lowered('bufferdeptstorefloorno') and VM.lowered('getstaticencounteroutcome'), + ('HGSS battle outcomes: caught %d/%d/%d, fled %d/%d/%d, lost %d/%d/%d, won %d/%d/%d') + :format(cw, co, cn, rw, ro, rn, lw, lo, ln, ww, wo, wn)) +end + +-- Morty's gym: init fogs the room to black and puts model 128 on the four +-- mediums (objects 2..5); beating one (no runner: at once) puts hers out and +-- leaves three; the lights' material is out of the fog (polyAttr bit 15) +do + local Cmd = require('src.script.Commands') + local E = require('src.world.HgssEcruteakGym') + local def = {} + local function npc(id, x, y) return { def = { localId = id }, px = x * 16, py = y * 16 } end + local mediums = { npc(2, 17, 39), npc(3, 19, 30), npc(4, 9, 29), npc(5, 11, 19), npc(6, 16, 49) } + local ow = { map = { def = def }, npcs = mediums } + local save = { vars = {} } + local Game = require('src.core.Game') + local savedSave = Game.save + Game.save = save + Cmd.g4_ecruteak_gym_init({ save = save, overworld = ow }) + local lit = #(def.gen4RuntimeProps or {}) + local first = def.gen4RuntimeProps and def.gen4RuntimeProps[1] + Cmd.g4_ecruteak_candle_out({ save = save, overworld = ow, npc = mediums[1] }) + local after = #(def.gen4RuntimeProps or {}) + Game.save = savedSave + local models = h and h.gen4_models + local spot = models and models.sets and models.sets.interiorBuildings + and models.sets.interiorBuildings.models[129] + check(def.gen4Fog and def.gen4Fog[1] == 0 and lit == 4 and first.model == 128 and first.x == 17 * 16 + 8 + and after == 3 and save.hgssGymmick.candles[1] == true and type(def.gen4PropFollow) == 'function' + and spot and spot.shapes[1].noFog == true, + ('Morty gym: fog, %d lights then %d, candle 1 out, light model out of the fog (%s)') + :format(lit, after, tostring(spot and spot.shapes[1].noFog))) +end + +-- HGSS's roamers on Platinum's rules: CreateRoamer 0 / 1 release Raikou and +-- Entei at 40; over 200 moves neither leaves Johto's 16 routes; a long hop +-- for Latias stays in Kanto; met one in two on its map +do + local Bands = require('src.import.Gen4ScriptBands') + local was = Bands.family + Bands.family = 'hgss' + local R = require('src.world.Gen4Roamers') + local data = { maps = (h and h.maps) or {} } + local save = {} + local seed = 1 + local function rng(a, b) seed = (seed * 1103515245 + 12345) % 2147483648; return a + seed % (b - a + 1) end + local raikou = R.activate(data, save, 0, rng) + R.activate(data, save, 1, rng) + local johto = {} + for i = 0, 15 do johto[R.mapOf(data, i)] = true end + local stayed = true + for _ = 1, 200 do + R.noteMap(save, 'T20') + R.moveAll(data, save, rng) + for slot = 0, 1 do if not johto[R.active(save, slot).map] then stayed = false end end + end + R.activate(data, save, 2, rng) + local latias = R.active(save, 2) + local kanto = (R.ROUTES[latias.route] and latias.route >= 16) + local met, tries = 0, 400 + local where = R.active(save, 0).map + for _ = 1, tries do if R.check(data, save, where, math.random) then met = met + 1 end end + local one = R.encounterFor(0, R.active(save, 0)) + check(raikou and raikou.species == 243 and raikou.level == 40 and stayed and kanto + and met > tries * 0.3 and one and one.species == 243 and R.SLOT_COUNT == 4 and R.ROUTE_COUNT == 41, + ('HGSS roamers: Raikou %s, stayed in Johto %s, Latias in Kanto %s (route %s), met %d / %d') + :format(tostring(raikou and raikou.map), tostring(stayed), tostring(kanto), tostring(latias.route), met, tries)) + Bands.family = was + R.useFamily() +end + +-- Loan Pokemon: Shuckie (trade 6) joins at 20; giving him back is refused +-- while he holds an item (2) or is the last able one (4), refused for +-- someone else's Pokemon (1), allowed otherwise (0); Kenya carries its mail. +-- And Chuck's winch turns once. +do + local Cmd = require('src.script.Commands') + local G4 = require('src.script.Gen4Commands') + local data = { constants = { gen4Trades = h and h.constants and h.constants.gen4Trades }, + pokemon = h and h.pokemon, moves = h and h.moves } + local save = { vars = {}, party = {} } + local ctx = { save = save, game = { data = data } } + -- the raw cache's species records lack what Data.lua adds at load, so a + -- minimal Pokemon stands in (the harness gives the real one) + local P = require('src.pokemon.Pokemon') + local realNew = P.new + P.new = function(_, species, level) return { species = species, level = level, hp = 50, moves = {} } end + Cmd.g4_give_loan_mon(ctx, 6, 20, 75) + local shuckie = save.party[1] + Cmd.g4_check_return_loan_mon(ctx, 6, 0, 0x8000); local alone = G4.getVar(save, 0x8000) + Cmd.g4_give_loan_mon(ctx, 7, 20, 101) + local kenya = save.party[2] + Cmd.g4_kenya_mail(ctx, 0x8001); local mail = G4.getVar(save, 0x8001) + shuckie.item = 1 + Cmd.g4_check_return_loan_mon(ctx, 6, 0, 0x8000); local holding = G4.getVar(save, 0x8000) + shuckie.item = nil + Cmd.g4_check_return_loan_mon(ctx, 6, 1, 0x8000); local wrong = G4.getVar(save, 0x8000) + Cmd.g4_check_return_loan_mon(ctx, 6, 0, 0x8000); local fine = G4.getVar(save, 0x8000) + Cmd.g4_return_loan_mon(ctx, 0) + P.new = realNew + Cmd.g4_cianwood_gym_init(ctx) + Cmd.g4_cianwood_turn_winch(ctx, 0x8002); local first = G4.getVar(save, 0x8002) + Cmd.g4_cianwood_turn_winch(ctx, 0x8002); local again = G4.getVar(save, 0x8002) + check(shuckie and shuckie.species == 213 and shuckie.level == 20 and alone == 4 and holding == 2 + and wrong == 1 and fine == 0 and #save.party == 1 and save.party[1] == kenya + and kenya.species == 21 and mail == 1 and first == 1 and again == 0, + ('Loans: Shuckie %s L%s, return answers %s/%s/%s/%s, Kenya %s mail %s; winch %s then %s') + :format(tostring(shuckie and shuckie.species), tostring(shuckie and shuckie.level), tostring(alone), + tostring(holding), tostring(wrong), tostring(fine), tostring(kenya and kenya.species), + tostring(mail), tostring(first), tostring(again))) +end + +-- The Rocket disguise (PLAYER_TRANSITION_ROCKET, 1 << 10) goes on and comes +-- off with the avatar bits and re-dresses the player; the hideout's +-- commands lower (traps, the tag battle beside Lance, ScrCmd_530's magic) +do + local Bands = require('src.import.Gen4ScriptBands') + local was = Bands.family + Bands.family = 'hgss' + local Cmd = require('src.script.Commands') + local G4 = require('src.script.Gen4Commands') + local VM = require('src.script.Gen4ScriptVM') + local refreshed = 0 + local player = { refreshForm = function() refreshed = refreshed + 1 end } + local save = { vars = {} } + local ctx = { save = save, overworld = { player = player }, game = { data = {} } } + Cmd.g4_player_state(ctx, 1024); local on = save.hgssRocketCostume + Cmd.g4_rocket_costume_flag(ctx, 1); Cmd.g4_rocket_costume_check(ctx, 0x8000) + local flag = G4.getVar(save, 0x8000) + Cmd.g4_player_state(ctx, 1); local off = save.hgssRocketCostume + Bands.family = was + check(on == true and off == nil and refreshed == 2 and flag == 1 + and VM.lowered('rockettrapbattle') and VM.lowered('multibattle') and VM.lowered('moveperson') + and VM.lowered('scrcmd_530') and VM.lowered('setavatarbits') and VM.lowered('createroamer') + and VM.lowered('hofcredits') and VM.lowered('halloffameanim'), + ('Rocket disguise on %s off %s (re-dressed %d), costume flag %d; hideout and Hall of Fame commands lowered') + :format(tostring(on), tostring(off), refreshed, flag)) +end + +-- Clair's platforms (blackthorn.c): on open magma the centre button turns +-- platform 1 a quarter in 16 frames and the side button carries it, and the +-- player, its width (5 tiles) in 10; a wall in the way bonks the move back +-- where it started; the floor is walkable and bare magma is not +do + local B = require('src.world.HgssBlackthornGym') + local function world(wallX) + local map = { def = {} } + function map:cellBehaviour(x, z) return (wallX and x >= wallX) and 0 or 0x2C end + function map:cellCollision() return 0 end + local p = { cellX = 13, cellY = 75, px = 13 * 16, py = 75 * 16 } + return { map = map, player = p }, map, p + end + local ow, map, p = world(nil) + local save = {} + B.attach(ow, save) + local started = map.def.gen4StepHook(ow, 13, 75) + for _ = 1, 20 do map.def.gen4PropFollow(ow) end + local turned = save.hgssGymmick.rot[1] + -- now rotated once (rot 1): the "right" button is (13, 76) and moves +z + p.cellX, p.cellY, p.px, p.py = 13, 76, 13 * 16, 76 * 16 + map.def.gen4StepHook(ow, 13, 76) + for _ = 1, 30 do map.def.gen4PropFollow(ow) end + local movedZ, playerZ = save.hgssGymmick.z[1], p.cellY + local floorOk = map.def.gen4CollisionHook(map, 13, 80 + 1) == false + local magmaBlocks = map.def.gen4CollisionHook(map, 30, 10) == true + -- a wall two tiles east: the east button bonks and the platform stays + local ow2, map2, p2 = world(17) + local save2 = {} + B.attach(ow2, save2) + p2.cellX, p2.px = 14, 14 * 16 + map2.def.gen4StepHook(ow2, 14, 75) + for _ = 1, 30 do map2.def.gen4PropFollow(ow2) end + check(started and turned == 1 and movedZ == 80 and playerZ == 81 and floorOk and magmaBlocks + and save2.hgssGymmick.x[1] == 13 and p2.cellX == 14 and p2.inputLocked == nil + and #map.def.gen4RuntimeProps == 3 and map.def.gen4RuntimeProps[1].ground == true, + ('Blackthorn platforms: turned to %s, moved to z %s carrying the player to %s, bonk kept x %s') + :format(tostring(turned), tostring(movedZ), tostring(playerZ), tostring(save2.hgssGymmick.x[1]))) +end + +-- Lt. Surge's cans: the second switch is a neighbour of the first; the can +-- check answers 1/0, then 2/3 with gate 1 open, then 4 with both; Fuchsia, +-- Viridian, the Cycling Road lock and the Latias/Latios picture lower +do + local Cmd = require('src.script.Commands') + local G4 = require('src.script.Gen4Commands') + local VM = require('src.script.Gen4ScriptVM') + local save = { vars = {} } + local ctx = { save = save, game = { data = {} } } + local neighbourOk = true + for _ = 1, 50 do + Cmd.g4_vermilion_resample(ctx) + local g = save.hgssGymmick + local okN = false + for _, n in ipairs(G4.VERMILION_CAN_NEIGHBOURS[g.switches[1]]) do if n == g.switches[2] then okN = true end end + if not okN then neighbourOk = false end + end + local g = save.hgssGymmick + local first, second = g.switches[1], g.switches[2] + local wrong = (first + 1) % 15 + if wrong == second then wrong = (wrong + 1) % 15 end + local function ask(can) Cmd.g4_vermilion_can_check(ctx, can, 0x8000); return G4.getVar(save, 0x8000) end + local a1, a0 = ask(first), ask(wrong) + Cmd.g4_vermilion_lock_action(ctx, 0, 0) + local b2, b3 = ask(second), ask(first) + Cmd.g4_vermilion_lock_action(ctx, 1, 0) + local c4 = ask(first) + check(neighbourOk and a1 == 1 and a0 == 0 and b2 == 2 and b3 == 3 and c4 == 4 + and VM.lowered('fuchsiagyminit') and VM.lowered('viridiangyminit') and VM.lowered('setbikestatelock') + and VM.lowered('scrcmd_452') and VM.lowered('resamplevermiliongymcans'), + ('Vermilion cans: answers %s/%s, then %s/%s, then %s; switch pairs neighbours %s') + :format(a1, a0, b2, b3, c4, tostring(neighbourOk))) +end + +print(('%d checks, %d failed'):format(PASS + FAIL, FAIL)) +if FAIL > 0 then os.exit(1) end diff --git a/tools/gen4_hgss_import_harness/conf.lua b/tools/gen4_hgss_import_harness/conf.lua new file mode 100644 index 000000000..4c98f2faf --- /dev/null +++ b/tools/gen4_hgss_import_harness/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + t.identity = "hgss_import" + t.window.width, t.window.height = 256, 192 + t.window.visible = false + t.modules.audio = false + t.modules.sound = false +end diff --git a/tools/gen4_hgss_import_harness/main.lua b/tools/gen4_hgss_import_harness/main.lua new file mode 100644 index 000000000..bcf04dc5c --- /dev/null +++ b/tools/gen4_hgss_import_harness/main.lua @@ -0,0 +1,56 @@ +-- tools/gen4_hgss_import_harness/main.lua +-- +-- A HEADLESS HEARTGOLD / SOULSILVER IMPORT, into the version's OWN folder. +-- +-- CACHE_ROOT=G:/Gen2Recomped VERSION=heartgold \ +-- ROM="./Pokemon - HeartGold Version (USA).nds" love tools/gen4_hgss_import_harness +-- +-- Writes under // -- GameVersion's cachePrefix, the same +-- place the launcher would -- so it never touches another version's cache. +-- It runs the stages Gen4Versions lists for the family and nothing else, and +-- prints the report (`gen4_version.notPorted` says what is still missing). +-- The ground, buildings and sprites it writes are what +-- tools/gen4_overworld_harness then draws (POKEPORT_DATA_DIR / ASSET_ROOT). + +function love.load() + local ok, err = xpcall(function() + local version = os.getenv("VERSION") or "heartgold" + local root = assert(os.getenv("CACHE_ROOT"), "set CACHE_ROOT to the install folder") + local rom = assert(os.getenv("ROM"), "set ROM to the cartridge path") + local GameVersion = require("src.core.GameVersion") + GameVersion.set(version) + local info = GameVersion.info and GameVersion.info(version) or {} + local prefix = info.cachePrefix or (version .. "/") + assert(prefix ~= "" and prefix:match("^[%w_]+/$"), "refusing an empty or odd cache prefix") + + local CacheFs = require("src.import.CacheFs") + CacheFs.prefix = prefix + CacheFs.root = function() return root end + + -- Black and White go through the Gen 5 extractor, into the same layout + local X = require(GameVersion.generation(version) == 5 and "src.import.RomExtractorGen5" + or "src.import.RomExtractorGen4") + local x = assert(X.new(rom, version)) + local t = os.clock() + -- STAGE_ONLY=models,fonts: just those stages, into the existing install, + -- leaving the version record (and every other stage's output) as it is + local only = os.getenv("STAGE_ONLY") + if only then + local methods = X.STAGE_METHODS or {} + for name in only:gmatch("[%w_]+") do + local method = methods[name] or name + assert(type(x[method]) == "function", "no stage " .. name) + x[method](x) + end + else + x:run() + end + x:close() + print(("imported %s into %s/%s in %.1fs"):format(version, root, prefix, os.clock() - t)) + for _, name in ipairs(x.wrote or {}) do io.write(name, " ") end + print() + print("not ported: " .. table.concat(x.notPorted or {}, ", ")) + end, debug.traceback) + if not ok then print("IMPORT FAILED: " .. tostring(err)) end + love.event.quit(ok and 0 or 1) +end diff --git a/tools/gen4_hgss_overworld_harness/conf.lua b/tools/gen4_hgss_overworld_harness/conf.lua new file mode 100644 index 000000000..8865eece3 --- /dev/null +++ b/tools/gen4_hgss_overworld_harness/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + t.identity = "hgss_ow" + t.window.width, t.window.height = 512, 384 + t.window.visible = false + t.modules.audio = false + t.modules.sound = false +end diff --git a/tools/gen4_hgss_overworld_harness/main.lua b/tools/gen4_hgss_overworld_harness/main.lua new file mode 100644 index 000000000..fdfc58d82 --- /dev/null +++ b/tools/gen4_hgss_overworld_harness/main.lua @@ -0,0 +1,356 @@ +-- tools/gen4_hgss_overworld_harness/main.lua +-- +-- tools/gen4_overworld_harness, standing on an INSTALLED version folder. +-- +-- INSTALL=G:/Gen2Recomped/heartgold VERSION=heartgold MAP=T20 X=14 Y=14 \ +-- love tools/gen4_hgss_overworld_harness +-- +-- The overworld harness reads data through POKEPORT_DATA_DIR and pictures +-- through POKEPORT_ASSET_ROOT, but the 3D ground opens its packed geometry +-- (`terrain/chunks.bin`) with love.filesystem, which only sees what is +-- mounted -- the launcher mounts a version's cache (CacheFs.mountVersion) and +-- a bare harness does not. So this mounts INSTALL at the root, read-only, +-- with the same PHYSFS_mount CacheFs uses, sets the two variables from it, and +-- hands over to the overworld harness unchanged. Pictures land in this +-- harness's own save folder (identity hgss_ow). + +local install = assert(os.getenv("INSTALL"), "set INSTALL to the version's folder") + +local ffi = require("ffi") +pcall(ffi.cdef, "int PHYSFS_mount(const char *newDir, const char *mountPoint, int appendToPath);") +local mounted = false +for _, lib in ipairs({ function() return ffi.C end, function() return ffi.load("love") end }) do + local ok, l = pcall(lib) + if ok and l then + local okf, fn = pcall(function() return l.PHYSFS_mount end) + if okf and fn and fn(install, "", 1) ~= 0 then mounted = true break end + end +end +print(("mounted %s: %s"):format(install, tostring(mounted))) + +-- OUT_DIR: write the harness's PNGs there (plain io) instead of LÖVE's save +-- folder -- for a machine whose system drive has no room +local outDir = os.getenv("OUT_DIR") +if outDir then + local probe = love.image.newImageData(1, 1) + local mt = getmetatable(probe) + local index = mt and mt.__index + local encode = index and index.encode + if encode then + index.encode = function(self, format, filename) + local data = encode(self, format) + if filename then + local f = assert(io.open(outDir .. "/" .. filename, "wb")) + f:write(data:getString()); f:close() + return data + end + return data + end + end +end + +-- the overworld harness reads these two at load time +local env = { POKEPORT_DATA_DIR = install .. "/data/generated", POKEPORT_ASSET_ROOT = install } +local getenv = os.getenv +os.getenv = function(k) return env[k] or getenv(k) end + +-- PARTY_DETAIL=1: with the overworld harness's "party after" line, each +-- member's species and friendship (what a script's friendship row changed) +if os.getenv("PARTY_DETAIL") then + local print0 = print + print = function(first, ...) + print0(first, ...) + if type(first) == "string" and first:match("^party after") then + local G = package.loaded["src.core.Game"] + local beaten = {} + for id, yes in pairs((G and G.save and G.save.gen4TrainerFlags) or {}) do + if yes then beaten[#beaten + 1] = tostring(id) end + end + table.sort(beaten) + print0(" trainers beaten: " .. (#beaten > 0 and table.concat(beaten, ",") or "none")) + local badges = {} + for id, yes in pairs((G and G.save and G.save.badges) or {}) do if yes then badges[#badges + 1] = tostring(id) end end + table.sort(badges) + print0(" badges: " .. (#badges > 0 and table.concat(badges, ",") or "none")) + -- SHOW_FLAGS="0x169,0x1D0": whether each is set at the end + for id in tostring(os.getenv("SHOW_FLAGS") or ""):gmatch("%w+") do + local C = require("src.script.Commands") + local c = { save = G.save, game = G } + C.check_flag(c, ("FLAG_G4_%04X"):format(tonumber(id))) + print0((" flag %s %s"):format(id, c.lastCheck and "set" or "clear")) + end + -- SHOW_SAVE="gen4SpecialLocation,hgssGymmick.candles": a save field (dotted), one level shown + for key in tostring(os.getenv("SHOW_SAVE") or ""):gmatch("[%w_%.]+") do + local v = G.save + for part in key:gmatch("[^%.]+") do v = type(v) == "table" and v[part] or nil end + local parts = {} + if type(v) == "table" then + for k, x in pairs(v) do parts[#parts + 1] = tostring(k) .. "=" .. tostring(x) end + table.sort(parts) + end + print0((" save.%s = %s"):format(key, type(v) == "table" and "{" .. table.concat(parts, ", ") .. "}" or tostring(v))) + end + -- SHOW_MAPS="R32,R_32": a loaded map def's header, generation and source + for id in tostring(os.getenv("SHOW_MAPS") or ""):gmatch("[%w_]+") do + local d = G.data and G.data.maps and G.data.maps[id] + print0((" map %s: %s"):format(id, d and ("id=%s same=%s header=%s generation=%s label=%s width=%s"):format(tostring(d.id), tostring(d == (G.data.maps[(id:gsub("_", ""))])), + tostring(d.header), tostring(d.generation), tostring(d.label), tostring(d.width)) or "absent")) + end + -- SHOW_LOCAL=0: every live entity answering to that local id + if os.getenv("SHOW_LOCAL") then + local want = tonumber(os.getenv("SHOW_LOCAL")) + local OWs = G.stack and G.stack.states or {} + for _, st in ipairs(OWs) do + for i, e in ipairs(st.entities or {}) do + local lid = e.localId or (e.def and e.def.localId) + if lid == want then + print0((" entity %d: localId %s index %s sprite %s at (%s,%s) player=%s"):format(i, tostring(lid), + tostring(e.def and e.def.index), tostring(e.def and (e.def.spriteName or e.def.sprite)), + tostring(e.cellX), tostring(e.cellY), tostring(e == st.player))) + end + end + end + end + -- SHOW_BUILDINGS=1: the ground's building set and, per placement in + -- view, whether its model resolves + if os.getenv("SHOW_BUILDINGS") then + for _, st in ipairs((G.stack and G.stack.states) or {}) do + local ground = st.map and st.map.renderer and st.map.renderer.gen4Ground + if ground then + local set = ground.buildingSet + print0((" building set %s (%s models)"):format(tostring(set and set.label), + tostring(set and set.models and #set.models))) + local where = {} + local grid = ground.grid + for i, l in ipairs((grid and grid.land) or {}) do + where[l] = where[l] or { (i - 1) % grid.width, math.floor((i - 1) / grid.width) } + end + for land, rec in pairs((ground.terrain and ground.terrain.chunks) or {}) do + local ok, objs = pcall(ground.objectsFor, ground, land, rec) + local cw = where[land] + if cw then print0((" land %s at chunk (%d,%d), map offset (%s,%s)"):format(tostring(land), cw[1], cw[2], + tostring(ground.offsetX), tostring(ground.offsetY))) end + if ok and objs and #objs > 0 and (not os.getenv("LAND") or tonumber(os.getenv("LAND")) == land) then + local parts = {} + for _, o in ipairs(objs) do + local b = ground:building(o.model, o.archive) + parts[#parts + 1] = ("%s%s@%.0f,%.0f,%.0f"):format(tostring(o.model), b and "" or "!", o.x or 0, o.y or 0, o.z or 0) + end + print0((" land %s: %s"):format(tostring(land), table.concat(parts, " "))) + end + end + end + end + end + -- SHOW_PLAYER=1: the player's position and state fields + if os.getenv("SHOW_PLAYER") then + for _, st in ipairs((G.stack and G.stack.states) or {}) do + local p = st.player + if p then + print0((" player cell (%s,%s) px (%s,%s) moving %s hidden %s locked %s elevation %s"):format( + tostring(p.cellX), tostring(p.cellY), tostring(p.px), tostring(p.py), tostring(p.moving), + tostring(p.hidden or p.invisible), tostring(p.inputLocked), tostring(p.elevation))) + end + end + end + -- SHOW_HEIGHTS="13,80;13,75": the ground's heights (world units) there + if os.getenv("SHOW_HEIGHTS") then + for _, st in ipairs((G.stack and G.stack.states) or {}) do + local ground = st.map and st.map.renderer and st.map.renderer.gen4Ground + if ground then + for x, z in tostring(os.getenv("SHOW_HEIGHTS")):gmatch("(%d+),(%d+)") do + local list = ground.heightsAt and ground:heightsAt(tonumber(x), tonumber(z)) or {} + local over = ground.heightOverride and ground:heightOverride(tonumber(x), tonumber(z)) + print0((" height %s,%s: %s (override %s)"):format(x, z, table.concat(list, "/"), tostring(over))) + end + end + end + end + local ap = G and G.save and G.save.hgssApricorns + if ap then + local picked = {} + for k in pairs(ap.picked or {}) do picked[#picked + 1] = k end + print0((" apricorns: box %s, Kurt %d of colour %d, trees picked %s"):format( + table.concat(ap.box, ","), ap.kurtQuantity or 0, ap.kurtColour or 0, table.concat(picked, ","))) + end + local inv = G and G.save and G.save.inventory or {} + for _, id in ipairs({ 492, 493, 494, 495, 496, 497, 498 }) do + local k = ("ITEM_%03d"):format(id) + if inv[k] then print0((" item %d x%s"):format(id, tostring(inv[k]))) end + end + for i, mon in ipairs((G and G.save and G.save.party) or {}) do + local moves = {} + for _, mv in ipairs(mon.moves or {}) do moves[#moves + 1] = tostring(mv.id) end + print0((" party %d: species %s friendship %s egg %s moves %s"):format(i, tostring(mon.species), + tostring(mon.happiness or mon.friendship), tostring(mon.isEgg or mon.egg or false), + table.concat(moves, ","))) + end + end + end +end + +-- CMD_TRACE="ecruteak|battle": print each script command whose name matches +-- one of the |-separated patterns, with its operands, as it runs +if os.getenv("CMD_TRACE") then + local Commands = require("src.script.Commands") + require("src.script.Gen4Commands") + local pats = {} + for p in tostring(os.getenv("CMD_TRACE")):gmatch("[^|]+") do pats[#pats + 1] = p end + for name, fn in pairs(Commands) do + if type(fn) == "function" then + for _, p in ipairs(pats) do + if name:find(p) then + Commands[name] = function(ctx, ...) + local a = {} + for i = 1, select("#", ...) do a[i] = tostring((select(i, ...))) end + print(("[cmd] %s(%s) npc=%s"):format(name, table.concat(a, ","), + tostring(ctx and ctx.npc and ctx.npc.def and ctx.npc.def.localId))) + return fn(ctx, ...) + end + break + end + end + end + end +end + +-- FOG_OFF=1: a map script's fog (Morty's gym) is dropped again at once, to +-- see what is under it +if os.getenv("FOG_OFF") then + local Commands = require("src.script.Commands") + require("src.script.Gen4Commands") + local init = Commands.g4_ecruteak_gym_init + Commands.g4_ecruteak_gym_init = function(ctx, ...) + init(ctx, ...) + local def = ctx.overworld and ctx.overworld.map and ctx.overworld.map.def + if def then def.gen4Fog = nil end + local list = def and def.gen4RuntimeProps or {} + for _, p in ipairs(list) do print(("[fog off] prop %s at %s,%s,%s"):format(p.model, p.x, p.y, p.z)) end + end +end + +-- AUTO_BATTLE=win|lose: a script's battle ends at once with that result, so +-- the branch the script takes after it can be watched without fighting +if os.getenv("AUTO_BATTLE") then + local Commands = require("src.script.Commands") + Commands.start_battle = function(ctx, kind, id) + ctx.lastBattleResult = os.getenv("AUTO_BATTLE") + print(("[auto battle] %s %s -> %s"):format(tostring(kind), tostring(id), ctx.lastBattleResult)) + end +end + +-- HARNESS picks another Gen 4 harness to run on the same mounted install +-- (gen4_battle_harness, ...); the overworld one by default +local harness = os.getenv("HARNESS") or "gen4_overworld_harness" +assert(harness:match("^gen4_[%w_]+_harness$"), "HARNESS names a tools/gen4_*_harness folder") +dofile(("tools/%s/main.lua"):format(harness)) + +-- CAM_TILT=cartridge|third|first|: the options menu's CAM TILT row, +-- chosen before the first frame -- Platinum's camera modes on this install +local camTilt = os.getenv("CAM_TILT") +if camTilt then + local load = love.load + love.load = function(...) + local r = { load(...) } + local Gen4Camera = require("src.render.Gen4Camera") + local G = package.loaded["src.core.Game"] + local at + for i, t in ipairs(Gen4Camera.TILTS) do + if tostring(t) == camTilt or (tonumber(camTilt) and tonumber(t) and math.abs(tonumber(t) - tonumber(camTilt)) < 0.5) then at = i end + end + assert(at, "CAM_TILT is one of: " .. table.concat((function() + local l = {} for _, t in ipairs(Gen4Camera.TILTS) do l[#l + 1] = tostring(t) end return l end)(), ", ")) + if G and G.save then G.save.options = G.save.options or {}; G.save.options.gen4CameraTilt = at end + Gen4Camera.setTilt(at) + print(("[cam] tilt %s (row %d)"):format(tostring(Gen4Camera.TILTS[at]), at)) + return (table.unpack or unpack)(r) + end +end + +-- SET_VARS="0x4108=1,0x4030=152", SET_FLAGS / CLEAR_FLAGS="0x6A,0x73", APRICORNS: story state +-- before the first frame, through the engine's own setters, so a later +-- scene can be tested without playing up to it +if os.getenv("SET_VARS") or os.getenv("SET_FLAGS") or os.getenv("CLEAR_FLAGS") or os.getenv("APRICORNS") or os.getenv("KURT") then + -- applied as the overworld is pushed: that is after the new save exists + -- and before the first tick (this harness ticks inside love.load) + local StateStack = require("src.core.StateStack") + local push, applied = StateStack.push, false + StateStack.push = function(self, state, ...) + if not applied and state == require("src.world.OverworldController") then + applied = true + local G = package.loaded["src.core.Game"] + local G4 = require("src.script.Gen4Commands") + local Commands = require("src.script.Commands") + local ctx = { save = G and G.save, game = G } + for id, value in tostring(os.getenv("SET_VARS") or ""):gmatch("(%w+)=(%w+)") do + G4.setVar(ctx.save, tonumber(id), tonumber(value)) + print(("[state] var %s = %s"):format(id, value)) + end + for id in tostring(os.getenv("SET_FLAGS") or ""):gmatch("%w+") do + Commands.set_flag(ctx, ("FLAG_G4_%04X"):format(tonumber(id))) + print(("[state] flag %s set"):format(id)) + end + for id in tostring(os.getenv("CLEAR_FLAGS") or ""):gmatch("%w+") do + Commands.clear_flag(ctx, ("FLAG_G4_%04X"):format(tonumber(id))) + print(("[state] flag %s clear"):format(id)) + end + -- APRICORNS="3=5,0=2": colour (0 red .. 6 black) = how many in the box + for colour, n in tostring(os.getenv("APRICORNS") or ""):gmatch("(%d+)=(%d+)") do + require("src.world.HgssApricorns").give(ctx.save, tonumber(colour), tonumber(n)) + print(("[state] apricorn %s x%s"):format(colour, n)) + end + -- KURT="2=3": Kurt already holds 3 of colour 2 (a day has passed) + for colour, n in tostring(os.getenv("KURT") or ""):gmatch("(%d+)=(%d+)") do + local A = require("src.world.HgssApricorns") + A.give(ctx.save, tonumber(colour), tonumber(n)) + A.setKurt(ctx.save, tonumber(colour), tonumber(n)) + print(("[state] Kurt holds %s of colour %s"):format(n, colour)) + end + end + return push(self, state, ...) + end +end + +-- ENC_FORCE=1: every step's rate check passes, so a short walk tests the +-- rest of the encounter path (table, slot, battle) instead of luck +if os.getenv("ENC_FORCE") then + local Encounter = require("src.world.Encounter") + Encounter.gen4StepAllowed = function() return true end +end + +-- ENC_DEBUG=1: every encounter roll and every grass-cell answer, logged +if os.getenv("ENC_DEBUG") then + local OW = require("src.world.OverworldController") + local roll = OW.rollEncounter + OW.rollEncounter = function(self, encDef, terrain) + local enc = roll(self, encDef, terrain) + print(("[enc] roll %s table=%s -> %s"):format(tostring(terrain), + tostring(encDef and encDef.grass and #encDef.grass or nil), enc and tostring(enc.species) or "none")) + return enc + end + local Map = require("src.world.Map") + local isEnc = Map.isEncounterCell + Map.isEncounterCell = function(self, cx, cy) + local yes = isEnc(self, cx, cy) + print(("[enc] cell %d,%d grass=%s"):format(cx, cy, tostring(yes))) + return yes + end +end + +-- PROBE_CELL=x,y: after load, say which chunk the loaded terrain has there +local probe = os.getenv("PROBE_CELL") +if probe then + local load = love.load + love.load = function(...) + local r = { load(...) } + local x, y = probe:match("(%d+),(%d+)") + local G = rawget(_G, "Game") + local data = G and G.data or package.loaded["src.core.Data"] + local t = data and data.gen4_terrain + local g = t and t.matrices and t.matrices[0] + print(("PROBE terrain source=%s grid=%sx%s land=%s"):format(tostring(t and t.source), + tostring(g and g.width), tostring(g and g.height), + tostring(g and g.land[tonumber(y) * g.width + tonumber(x) + 1]))) + return table.unpack and table.unpack(r) or unpack(r) + end +end diff --git a/tools/gen4_hgss_scrcmd_gen.py b/tools/gen4_hgss_scrcmd_gen.py new file mode 100644 index 000000000..8c2e9470e --- /dev/null +++ b/tools/gen4_hgss_scrcmd_gen.py @@ -0,0 +1,94 @@ +"""Generate src/import/Gen4HgssScriptOps.lua from pret/pokeheartgold. + + python tools/gen4_hgss_scrcmd_gen.py [path to pokeheartgold] + +HeartGold and SoulSilver's script commands are NOT Platinum's: 854 of them, +renumbered. Their operand widths are READ from asm/macros/script.inc -- the +macros the cartridge's scripts were assembled with, so what a macro emits is +exactly what the bytes hold (a macro that takes an argument it never emits, +like ScrCmd_403's second, emits nothing for it, and neither does the ROM). + +Each macro whose body opens with `.short ` is a command: the number is +the opcode and the .byte/.short/.hword/.word lines after it are the operands, +b/w/w/d. A macro that branches (.if) has no fixed width and is written as "*" +for the hand-written rules in the Lua module. Several macros may name one +opcode (aliases); they must agree on the width, and the first name is kept. +The command count comes from src/data/fieldmap/script_cmd_table.h. +""" +import os +import re +import sys + +root = sys.argv[1] if len(sys.argv) > 1 else "/tmp/phg" +inc = open(os.path.join(root, "asm/macros/script.inc"), encoding="utf-8").read() +table = open(os.path.join(root, "src/data/fieldmap/script_cmd_table.h"), encoding="utf-8").read() +handlers = re.findall(r"^\s*(ScrCmd_\w+|\w+),\s*$", table.split("gScriptCmdTable[] = {")[1].split("};")[0], re.M) +count = len(handlers) + +WIDTH = {".byte": "b", ".short": "w", ".hword": "w", ".word": "d"} +cmds, conflicts, jumps = {}, [], set() +for m in re.finditer(r"^\s*\.macro\s+(\w+)([^\n]*)\n(.*?)^\s*\.endm", inc, re.M | re.S): + name, body = m.group(1), m.group(3) + lines = [l.split("//")[0].strip() for l in body.split("\n")] + lines = [l for l in lines if l] + if not lines: + continue + first = re.match(r"^\.short\s+(\d+)$", lines[0]) + if not first: + continue + op = int(first.group(1)) + spec, variable, ok, relative = "", False, True, False + for l in lines[1:]: + if re.search(r"-\s*\.\s*-\s*4", l): + relative = True # `\dest-.-4`: a jump, relative to the operand's end + d = l.split()[0] + if d in WIDTH: + spec += WIDTH[d] + elif d.startswith(".if") or d in (".else", ".endif", ".elseif"): + variable = True + else: + ok = False # calls another macro: a compound, not one command + break + if not ok: + continue + if variable: + spec = "*" + if op in cmds: + if cmds[op][1] != spec: + conflicts.append((op, cmds[op], (name, spec))) + continue + cmds[op] = (name, spec) + if relative: + jumps.add(op) + +if conflicts: + for c in conflicts: + print("CONFLICT", c) + sys.exit(1) + +missing = [i for i in range(count) if i not in cmds] +out = [] +out.append("-- GENERATED by tools/gen4_hgss_scrcmd_gen.py from pret/pokeheartgold") +out.append("-- asm/macros/script.inc (the operand widths) and") +out.append("-- src/data/fieldmap/script_cmd_table.h (the count). Do not edit; regenerate.") +out.append("-- { macro name, lowercased name, spec }; \"*\" is variable (Gen4HgssScriptOps).") +out.append("return {") +out.append(" count = %d," % count) +out.append(" handlers = {") +for i, h in enumerate(handlers): + out.append(' [%d] = "%s",' % (i, h)) +out.append(" },") +out.append(" commands = {") +for op in sorted(cmds): + if op >= count: + continue + name, spec = cmds[op] + out.append(' [0x%03X] = { "%s", "%s" },' % (op, name, spec)) +out.append(" },") +out.append(" -- commands whose last operand is a jump (`dest-.-4` in the macro);") +out.append(" -- ApplyMovement's points at movement data rather than code") +out.append(" relative = { %s }," % ", ".join("[0x%03X] = true" % j for j in sorted(jumps) if j < count)) +out.append("}") +dst = os.path.join(os.path.dirname(__file__), "..", "src", "import", "Gen4HgssScriptTable.lua") +open(dst, "w", encoding="utf-8", newline="\n").write("\n".join(out) + "\n") +print("%d handlers, %d commands with macros, %d without: %s" % (count, len(cmds), len(missing), missing[:40])) diff --git a/tools/gen4_hgss_sprites_gen.py b/tools/gen4_hgss_sprites_gen.py new file mode 100644 index 000000000..382375595 --- /dev/null +++ b/tools/gen4_hgss_sprites_gen.py @@ -0,0 +1,70 @@ +"""Generate src/import/Gen4HgssObjectGfx.lua from pret/pokeheartgold. + + python tools/gen4_hgss_sprites_gen.py [path to pokeheartgold] + +HeartGold and SoulSilver's overworld-sprite table, ov01_022074A8 in +asm/overlay_01_sprite_data.s: one row per sprite id, + + .short SPRITE_, MMODEL_, | ( << 10) + +ending at SPRITE 0xFFFF. SPRITE_ ids are what a map's object events carry +(include/constants/sprites.h); MMODEL_ values are members of mmodel.narc +(include/constants/mmodel.h). The third short is kept as the asm spells it, +split into its low ten bits and its top six (`anim`). Constants are evaluated +from the headers, including the ones defined as sums of others. +""" +import os +import re +import sys + +root = sys.argv[1] if len(sys.argv) > 1 else "/tmp/phg" + + +def defines(path, consts): + for line in open(os.path.join(root, path), encoding="utf-8"): + m = re.match(r"\s*#define\s+(\w+)\s+(.+?)\s*(//.*)?$", line) + if m and not m.group(1).startswith("POKEHEARTGOLD"): + consts[m.group(1)] = m.group(2) + + +raw = {} +for header in ("include/constants/follow_mon_idx.h", "include/constants/mmodel.h", + "include/constants/sprites.h"): + if os.path.exists(os.path.join(root, header)): + defines(header, raw) + + +def value(expr, depth=0): + expr = expr.strip() + if depth > 20: + raise ValueError(expr) + if re.fullmatch(r"0x[0-9A-Fa-f]+|\d+", expr): + return int(expr, 0) + names = re.findall(r"[A-Za-z_]\w*", expr) + env = {n: value(raw[n], depth + 1) for n in names if n in raw} + missing = [n for n in names if n not in raw] + if missing: + raise KeyError(missing) + return int(eval(expr, {"__builtins__": {}}, env)) + + +asm = open(os.path.join(root, "asm/overlay_01_sprite_data.s"), encoding="utf-8").read() +table = asm.split("ov01_022074A8:")[1].split(".data")[0] +rows = [] +for m in re.finditer(r"\.short\s+(\w+),\s*(\w+),\s*(0x[0-9A-Fa-f]+)\s*\|\s*\((\d+)\s*<<\s*10\)", table): + sprite, model, low, anim = m.groups() + sid = value(sprite) if sprite in raw else int(sprite, 0) + if sid == 0xFFFF: + break + rows.append((sid, sprite, value(model), model, int(low, 16), int(anim))) + +out = ["-- GENERATED by tools/gen4_hgss_sprites_gen.py from pret/pokeheartgold", + "-- asm/overlay_01_sprite_data.s (ov01_022074A8) with sprites.h and mmodel.h.", + "-- Do not edit; regenerate. byId[sprite] = { member, name, bits, anim }.", + "return {", " count = %d," % len(rows), " byId = {"] +for sid, sprite, member, model, low, anim in rows: + out.append(' [%d] = { %d, "%s", 0x%03X, %d },' % (sid, member, sprite[len("SPRITE_"):].lower(), low, anim)) +out += [" },", "}"] +dst = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src", "import", "Gen4HgssObjectGfx.lua") +open(dst, "w", encoding="utf-8", newline="\n").write("\n".join(out) + "\n") +print("%d sprite rows; members %d..%d" % (len(rows), min(r[2] for r in rows), max(r[2] for r in rows))) diff --git a/tools/gen4_overworld_harness/main.lua b/tools/gen4_overworld_harness/main.lua index 049430138..8e5cc13ba 100644 --- a/tools/gen4_overworld_harness/main.lua +++ b/tools/gen4_overworld_harness/main.lua @@ -314,8 +314,22 @@ function love.load() -- party is a test that cannot fail. if os.getenv("PARTY") then local Pokemon = require("src.pokemon.Pokemon") - Game.save.party = { Pokemon.new(Data, tonumber(os.getenv("PARTY")), + Game.save.party = { Pokemon.new(Data, tonumber(os.getenv("PARTY"):match("^%d+")), tonumber(os.getenv("PARTYLEVEL") or "12")) } + -- PARTY=495,515: more than one (the rest at the same level) + for id in os.getenv("PARTY"):gmatch(",(%d+)") do + Game.save.party[#Game.save.party + 1] = Pokemon.new(Data, tonumber(id), + tonumber(os.getenv("PARTYLEVEL") or "12")) + end + -- PARTYMOVES=15,33: the lead's moves (a field-move test: Cut, Surf...) + if os.getenv("PARTYMOVES") then + local mon = Game.save.party[1] + mon.moves = {} + for id in os.getenv("PARTYMOVES"):gmatch("%d+") do + local m = Data.moves and Data.moves[tonumber(id)] + mon.moves[#mon.moves + 1] = { id = tonumber(id), pp = m and m.pp or 10 } + end + end end -- HURT THE PARTY FIRST, so "it healed" is a change and not a constant. -- A heal test on a full-health party passes whether or not anything runs. diff --git a/tools/gen4_particle_flip_patch/conf.lua b/tools/gen4_particle_flip_patch/conf.lua new file mode 100644 index 000000000..9a1c62227 --- /dev/null +++ b/tools/gen4_particle_flip_patch/conf.lua @@ -0,0 +1,5 @@ +function love.conf(t) + t.window.width, t.window.height = 400, 300 + t.identity = "bt_gen4" + t.console = false +end diff --git a/tools/gen4_particle_flip_patch/main.lua b/tools/gen4_particle_flip_patch/main.lua new file mode 100644 index 000000000..b6031eb9b --- /dev/null +++ b/tools/gen4_particle_flip_patch/main.lua @@ -0,0 +1,55 @@ +-- tools/gen4_particle_flip_patch/main.lua +-- +-- Adds each particle texture's FLIP bits (SPLTextureParam bits 14-15, read by +-- Gen4Particle.textures) to an EXISTING cache's gen4_particles records, so a +-- move's quarter-circle textures tile into rings without a re-import. +-- +-- love tools/gen4_particle_flip_patch + +function love.load(args) + local ok, err = xpcall(function() + local romPath, cacheDir = args[1], args[2] + assert(romPath and cacheDir, "usage: love tools/gen4_particle_flip_patch ") + local P = require("src.import.Gen4Particle") + local Narc = require("src.import.NarcArchive") + local G = require("src.import.Gen4Graphics") + local rom = assert(require("src.import.NdsRom").open(romPath)) + local path = cacheDir .. "/gen4_particles.lua" + local index = assert(dofile(path)) + local sources = { + { key = "move", path = P.ARCHIVE_MOVES }, + { key = "ball", path = P.ARCHIVE_BALLS }, + { key = "opening", path = P.ARCHIVE_FIELD, first = 4, last = 4 }, + } + local flipped, seen = 0, 0 + for _, src in ipairs(sources) do + local raw = rom:read(src.path) + local arc = raw and Narc.parse(raw) + if arc then + for member = src.first or 0, src.last or (arc.count - 1) do + local data = arc:get(member) + if data and G.isCompressed(data) then data = G.decompress(data) end + local list = data and P.textures(data) + local entry = index.effects and index.effects[src.key .. "_" .. member] + if list and entry and entry.textures then + for i, t in ipairs(list) do + local rec = entry.textures[i] + if rec then + seen = seen + 1 + rec.flipS = t.flipS or nil + rec.flipT = t.flipT or nil + if t.flipS or t.flipT then flipped = flipped + 1 end + end + end + end + end + end + end + local f = assert(io.open(path, "wb")) + f:write(require("src.import.LuaWriter").encode(index)) + f:close() + print(("%d textures read, %d flipped, written to %s"):format(seen, flipped, path)) + end, debug.traceback) + if not ok then print(err) end + love.event.quit() +end diff --git a/tools/gen4_pc_mart_harness/main.lua b/tools/gen4_pc_mart_harness/main.lua index 3e771f943..b5cd77aca 100644 --- a/tools/gen4_pc_mart_harness/main.lua +++ b/tools/gen4_pc_mart_harness/main.lua @@ -111,7 +111,32 @@ function love.load() { "box 2 wallpaper", function(s) s.game.save.currentBox = 2; s.row, s.col = 1, 1 end }, { "header menu", function(s) s.row = 0; s:choose() end }, { "wallpaper page", function(s) s.row = 0; s:choose(); s:runAction("WALLPAPER"); s:runAction(s.menu[2].key); s.action = 3 end }, - { "jump", function(s) s.row = 0; s:choose(); s:runAction("JUMP"); s.action = 12 end }, + { "jump: band opening", function(s) s.row = 0; s:choose(); s:runAction("JUMP"); for _ = 1, 3 do s:update(1 / 60) end end }, + { "jump: thumbnails fading in", function(s) s.row = 0; s:choose(); s:runAction("JUMP"); for _ = 1, 9 do s:update(1 / 60) end end }, + { "jump: open, box 3 picked", function(s) + s.row = 0; s:choose(); s:runAction("JUMP"); for _ = 1, 20 do s:update(1 / 60) end + s.game.input = { wasPressed = function(_, k) return k == "right" end, isDown = function() return false end } + s:update(1 / 60); s:update(1 / 60) + s.game.input = { wasPressed = function() return false end, isDown = function() return false end } + end }, + { "jump -> slide, mid-way", function(s) + s.row, s.col = 1, 1; s:openJump(); for _ = 1, 20 do s:update(1 / 60) end + s.jump.box = 2; s:closeJump(2); for _ = 1, 15 do s:update(1 / 60) end + end }, + { "R: slide, frame 8", function(s) + s.row, s.col = 1, 1 + s.game.input = { wasPressed = function(_, k) return k == "r" end, isDown = function() return false end } + s:update(1 / 60) + s.game.input = { wasPressed = function() return false end, isDown = function() return false end } + for _ = 1, 7 do s:update(1 / 60) end + end }, + { "R: slide done (item box shows the ring)", function(s) + s.row, s.col = 1, 1 + s.game.input = { wasPressed = function(_, k) return k == "r" end, isDown = function() return false end } + s:update(1 / 60) + s.game.input = { wasPressed = function() return false end, isDown = function() return false end } + for _ = 1, 40 do s:update(1 / 60) end + end }, { "close box button", function(s) s.row, s.col = 6, 5; s.game.save.boxes[1].wallpaper = 21 end }, { "egg + release", function(s) s.row, s.col = 2, 3; s:choose(); s:runAction("RELEASE") end }, { "mark menu", function(s) s.row, s.col = 1, 1; s:choose(); s:runAction("MARK"); s:pickMenuRow(2); s.action = 4 end }, @@ -143,6 +168,56 @@ function love.load() end sheet(pages, "pc.png") + -- -------------------------------------------- the PC's bottom screen -- + -- pc_bottom.png: each scene's top screen beside its touch screen + -- (Gen4BoxMenu:drawBottom, ov19_021DCF88 / ov19_021DEC04) + local function frames(s, n) for _ = 1, n do s:update(1 / 60) end end + local bottomScenes = { + { "idle", "move", function(s) end }, + { "box dial: button pressed", "move", function(s) s:setDial(PC.DIAL_BOX); frames(s, 6) end }, + { "box dial: cover fading in", "move", function(s) s:setDial(PC.DIAL_BOX); frames(s, 30) end }, + { "box dial: items sliding in", "move", function(s) s:setDial(PC.DIAL_BOX); frames(s, 52) end }, + { "box dial: open, names", "move", function(s) s:setDial(PC.DIAL_BOX); frames(s, 120) end }, + { "box dial: turned +1, mid-way", "move", function(s) + s:setDial(PC.DIAL_BOX); frames(s, 120) + s:bottomTouch(45, 130); s:dialMove(65, 160); frames(s, 3) end }, + { "box dial: jumped to box 2", "move", function(s) + s:setDial(PC.DIAL_BOX); frames(s, 120) + s:bottomTouch(45, 130); s:dialMove(65, 160); frames(s, 8); s:dialEnd(); frames(s, 60) end }, + { "markings dial: open", "move", function(s) s:setDial(PC.DIAL_MARKS); frames(s, 140) end }, + { "markings: circle filter", "move", function(s) + s:setDial(PC.DIAL_MARKS); frames(s, 140) + -- button 1 at (163, 134) is kind scroll + 1 = ITEM; button 3 = triangle.. + s:bottomTouch(184, 104); frames(s, 2) end }, + { "markings: closing", "move", function(s) + s:setDial(PC.DIAL_MARKS); frames(s, 140); s:setDial(PC.DIAL_NONE); frames(s, 10) end }, + { "move items", "items", function(s) s.row, s.col = 1, 1 end }, + { "compare: battle", "compare", function(s) + s.row, s.col = 1, 1; s:toggleCompareSlot(); s.row, s.col = 1, 3 end }, + { "compare: contest", "compare", function(s) + s.row, s.col = 1, 1; s:toggleCompareSlot(); s.row, s.col = 1, 3; s:nextCompareMode() end }, + { "compare: moves", "compare", function(s) + s.row, s.col = 1, 1; s:toggleCompareSlot(); s.row, s.col = 1, 2; s:nextCompareMode(); s:nextCompareMode() end }, + { "compare: egg", "compare", function(s) s.row, s.col = 2, 3 end }, + } + local bpages = {} + for _, sc in ipairs(bottomScenes) do + local save = pcSave() + save.boxes[1][1].stats = { hp = 21, attack = 12, defense = 11, spAttack = 9, spDefense = 10, speed = 8 } + save.boxes[1][1].contest = { cool = 120, beauty = 40, cute = 255, smart = 10, tough = 80 } + save.boxes[1][1].nature = "BOLD" + save.boxes[1][1].moves = { { id = 33 }, { id = 45 }, { id = 55 } } + save.boxes[1][3].contest = { cool = 200, beauty = 200, cute = 30, smart = 160, tough = 10 } + save.boxes[1][3].markings = 2 + local screen = PC.new(newGame(save), { mode = sc[2] }) + screen.t = 0 + sc[3](screen) + bpages[#bpages + 1] = render(function() screen:draw() end) + bpages[#bpages + 1] = render(function() screen:drawBottom() end) + print("pc bottom", sc[1]) + end + sheet(bpages, "pc_bottom.png", 4) + -- ------------------------------------------------------ the main menu -- local save = { player = { name = "LUCAS", gender = "male" }, pokedex = { seen = { [1] = true, [4] = true }, owned = {} }, badges = {}, playSeconds = 3 * 3600 + 7 * 60 } diff --git a/tools/gen4_pc_town_map_bottom_check.lua b/tools/gen4_pc_town_map_bottom_check.lua new file mode 100644 index 000000000..8078e82de --- /dev/null +++ b/tools/gen4_pc_town_map_bottom_check.lua @@ -0,0 +1,149 @@ +-- tools/gen4_pc_town_map_bottom_check.lua -- the bottom screens of Platinum's +-- PC storage (ov19_021DCF88 / ov19_021DEC04, src/ui/Gen4BoxMenu.lua) and Town +-- Map (graphics.c, src/ui/Gen4TownMap.lua), as logic (no LOVE, no cache): +-- +-- * PC: the dial ring's steps (touch_dial.c: 0 / 1 / 2 past 4.219 / 33.75 +-- degrees, sign by the dial's side); a corner tap opens the BOX dial, the +-- open runs press -> SUB_1 scroll -> cover fade -> items; a turn of one +-- step moves the offset and the box is jumped to 20 frames after the +-- stylus leaves; the pad closes a dial; MOVE ITEMS has no markings dial; +-- the markings filter mask (SetMonSpriteTransparencyMask) and which icons +-- it fades (ov19_021DA63C); the arc's end stops; compare mode's slots +-- * Town Map: the map history ring (overworld_map_history.c) and what the +-- map shows of it (TownMapContext_Init / LoadLocationHistory); the zoom +-- button's switch both ways and the touch rectangle back +-- +-- python tools/run_lua_check.py tools/gen4_pc_town_map_bottom_check.lua + +package.path = "./?.lua;" .. package.path +package.loaded['src.render.Font'] = { width = function(s) return #tostring(s) * 6 end, fit = function(s) return s end, + pushStyle = function() end, popStyle = function() end, draw = function() end, drawBox = function() end, + drawCode = function() end, drawDialogueBox = function() end } +package.loaded['src.core.Logger'] = { warn = function() end, info = function() end } +package.loaded['src.render.Assets'] = { image = function() return nil end, register = function() end } +package.loaded['src.ui.Theme'] = { cursor = 0 } +package.loaded['src.core.Sound'] = { play = function() end, playCry = function() end } +package.loaded['src.ui.SecondScreen'] = { mode = function() return "display" end, stowed = function() return false end, + combined = function() return false end, draw = function() end, toLocal = function(_, x, y) return x, y end } + +local PASS, FAIL = 0, 0 +local function check(cond, what) + if cond then PASS = PASS + 1 else FAIL = FAIL + 1; print("FAIL: " .. what) end +end +local function keys(...) + local set = {} + for _, k in ipairs({ ... }) do set[k] = true end + return { wasPressed = function(_, k) return set[k] or false end, isDown = function(_, k) return set[k] or false end } +end +local idle = keys() + +-- ------------------------------------------------------------------- PC -- +local Boxes = require('src.pokemon.Boxes') +local PC = require('src.ui.Gen4BoxMenu') +local function pcGame(mode) + local save = { party = { { species = 25, level = 5, hp = 10 } }, options = {}, inventory = {}, currentBox = 1 } + Boxes.ensure(save) + save.boxes[1][1] = { species = 387, level = 5, markings = 1, item = 17 } + save.boxes[1][2] = { species = 390, level = 5, markings = 0 } + save.boxes[1][3] = { species = 393, level = 5, markings = 5 } + local game = { data = { text = {}, pokemon = {}, items = {} }, save = save, input = idle, stack = { pop = function() end } } + return PC.new(game, { mode = mode or 'move' }), game +end +local function frames(s, n) for _ = 1, n do s:update(1 / 60) end end + +check(PC.dialSteps(300, 303, 1) == 0, 'under 4.219 degrees: no step') +check(PC.dialSteps(300, 310, 1) == 1 and PC.dialSteps(300, 340, 1) == 2, '4.219 / 33.75 degrees: one, two steps') +check(PC.dialSteps(300, 290, 1) == -1 and PC.dialSteps(300, 310, -1) == -1, 'the sign: turn and side') +local x0, y0 = PC.arcPoint(0) +local x7, y7 = PC.arcPoint(7) +check(x0 == 16 and y0 == -16 and x7 == 106 and y7 == 200, 'the arc ends (Unk_ov19_021E0264 0 / 7)') + +local s, game = pcGame() +s:touchpressed('t', 10, 180) +check(s.subState.dial == PC.DIAL_BOX and s.subState.task, 'a tap on the left corner opens the BOX dial') +frames(s, 20) +check(s.subState.sub1 ~= 0 and s.subState.sub1 > -96, 'after the press, SUB_1 scrolls') +frames(s, 20) +check(s.subState.sub1 == -96 and s.subState.cover == -128, 'SUB_1 at -96, SUB_2 at -128') +frames(s, 80) +check(not s:subBusy() and s.subState.names, 'items in, box names up') +-- a one-step turn in the ring about (0, 192), then off the ring +s:touchpressed('t', 45, 130) +s:touchmoved('t', 65, 160) +check(s.subState.offset == 1 and s.subState.scroll, 'one step clockwise: the dial offers box 2') +s:touchreleased('t') +frames(s, 10) +check(game.save.currentBox == 1, 'no jump before 20 frames') +frames(s, 20) +check(game.save.currentBox == 2, '...then box 2 (TOUCH_BOX_JUMP_WAIT_FOR_INPUT_OR_JUMP)') +frames(s, 40) +game.input = keys('a'); s:update(1 / 60); game.input = idle +check(s.subState.dial == PC.DIAL_NONE and s.subState.task, 'A closes the dial') +frames(s, 40) +check(s.subState.sub1 == 0 and not s.subState.task, 'closed: SUB_1 back at 0') + +-- the markings dial and its filter +game.save.currentBox = 1 +s:touchpressed('u', 250, 180); s:touchreleased('u') +frames(s, 160) +check(s.subState.dial == PC.DIAL_MARKS and not s:subBusy(), 'the right corner opens the markings dial') +s:touchpressed('u', 184, 104); s:touchreleased('u') -- button 3 = kind 2 = circle +check(s:markMask() == 2, 'circle: mask bit 1') +check(not s:iconFaded(game.save.boxes[1][1]) and s:iconFaded(game.save.boxes[1][2]), 'unmarked Pokemon fade') +s:touchpressed('u', 163, 134); s:touchreleased('u') -- kind 1 = ITEM +check(s:markMask() == 3 and s:iconFaded(game.save.boxes[1][3]), 'ITEM: no item fades too') +s:touchpressed('u', 152, 168); s:touchreleased('u') -- OFF +check(s:markMask() == 0 and s:markLit(0), 'OFF clears the filter and lights') + +-- MOVE ITEMS +local it = pcGame('items') +it:touchpressed('v', 250, 180) +check(it.subState.dial == PC.DIAL_NONE, 'MOVE ITEMS: no markings dial') +check(it:iconFaded(it.game.save.boxes[1][2]) and not it:iconFaded(it.game.save.boxes[1][1]), 'MOVE ITEMS fades the itemless') + +-- compare +local c = pcGame('compare') +c.row, c.col = 1, 1 +check(c:compareMon(0) == c.game.save.boxes[1][1] and c:compareMon(1) == nil, 'compare: the cursor fills the active slot') +c:touchpressed('w', 10, 180); c:touchreleased('w') +c.col = 2 +check(c.compare.slot == 1 and c:compareMon(0) == c.game.save.boxes[1][1] and c:compareMon(1) == c.game.save.boxes[1][2], + 'the left button sets it; the cursor now fills the right') +c:touchpressed('w', 128, 178); c:touchreleased('w') +check(c.compare.mode == 1, 'the middle button turns the page') + +-- ------------------------------------------------------------- Town Map -- +local TM = require('src.ui.Gen4TownMap') +local save = {} +for _, p in ipairs({ { 3, 27 }, { 3, 27 }, { 3, 26 }, { 4, 25 }, { 3, 26 }, { 4, 23 } }) do TM.pushHistory(save, p[1], p[2], 1) end +check(save.gen4MapHistory.pointer == 5, 'a push only when the cell changes') +local hist = TM.historyEntries(save, 9, 9) +check(#hist == 3 and hist[1].x == 3 and hist[1].z == 26 and hist[3].x == 3 and hist[3].z == 27, + 'the entries before the current one, newest first, one per cell') +check(#TM.historyEntries(save, 3, 26) == 2, 'the player\'s own cell is left out') +check(#TM.historyEntries({}, 0, 0) == 0, 'no record, no dots') + +local tgame = { data = { gen4_town_map = { blocks = {}, flyLocations = {}, firstArrival = {} }, + gen4_town_map_ink = { zoomHit = { { 100, 0, 255 } } }, text = {} }, save = { flags = {} }, + input = idle, stack = { pop = function() end } } +local m = TM.new(tgame, { mode = 'map' }) +m:touchpressed('a', 10, 50) +check(not m.switching, 'a tap on colours 1 / 4 is not the button') +m:touchreleased('a') +m:touchpressed('a', 128, 100) +check(m.switching and m.switching.to, 'a tap elsewhere presses the zoom button') +for _ = 1, 40 do m:update() end +check(m.zoomed and not m.switching, 'the zoomed map is up') +m:touchreleased('a') +m:touchpressed('a', 20, 20) +check(not m.switching, 'zoomed: only the button rectangle goes back') +m:touchreleased('a') +m:touchpressed('a', 120, 100) +for _ = 1, 20 do m:update() end +check(not m.zoomed, 'and it does') +local wall = TM.new(tgame, { mode = 'wall' }) +wall:touchpressed('b', 128, 100) +check(not wall.switching, 'the wall map takes no touch') + +print(("%d checks, %d failed"):format(PASS + FAIL, FAIL)) +if FAIL > 0 then os.exit(1) end diff --git a/tools/gen4_poketch_harness/main.lua b/tools/gen4_poketch_harness/main.lua index 4d5c0010e..44168225e 100644 --- a/tools/gen4_poketch_harness/main.lua +++ b/tools/gen4_poketch_harness/main.lua @@ -113,6 +113,19 @@ function love.load(args) { "Matchup Checker", function(w, s) s.matchup = { 1, 2 }; s.compatibility = 70 end }, { "Dowsing Machine" }, { "Marking Map" }, { "Berry Searcher" }, { "Color Changer", function(w, s) s.color = 3 end }, { "Trainer Counter" }, + -- the app change: the shutter closing, then held shut with the number + { "Counter", function(w) w:cycle(1) end, 6 }, + { "Counter", function(w) w:cycle(1); w:updatePresentation(1 / 60); w:cycle(1) end, 14 }, + { "Counter", function(w) w:cycle(1) end, 20 }, + { "Dowsing Machine", function(w) + w.dowsingResult = { kind = 2, x = 112, y = 101, items = { { x = 134, y = 90, range = 1 } } } + w.dowsingStart = 0 end, 14 }, + { "Dowsing Machine", function(w) + w.dowsingResult = { kind = 2, x = 112, y = 101, items = { { x = 134, y = 90, range = 1 } } } + w.dowsingStart = 0 end, 50 }, + { "Roulette", function(w) w:rouletteButton(1) end, 30 }, + { "Matchup Checker", function(w, s) s.matchup = { 1, 2 }; w:matchupTouch(112, 148) end, 200 }, + { "Pokémon List", function(w, s) w.game.save.party[4].hp = 30 end }, } local pages = {} for _, sc in ipairs(scenes) do diff --git a/tools/gen4_poketch_services_check.lua b/tools/gen4_poketch_services_check.lua index 53d6b10da..fb426e703 100644 --- a/tools/gen4_poketch_services_check.lua +++ b/tools/gen4_poketch_services_check.lua @@ -35,6 +35,8 @@ game.input = { wasPressed = function() return false end } watch:update(0.5) require('src.pokemon.Gen4PoketchState').tick(game, 0.5) check(game.save.poketch.stopwatch == 0.5, 'stopwatch advances') +-- the cartridge's START does nothing on 00:00 (StartTimer returns 0), so a time is set first +game.save.poketch.timerDuration = 60 watch.index = 5; watch:activate(); watch:update(1) require('src.pokemon.Gen4PoketchState').tick(game, 1) check(game.save.poketch.timer == 59, 'timer counts down') @@ -301,24 +303,35 @@ check(game.save.poketch.calculator.display=='10','calculator painted keys work t app('Memo Pad'); game.save.poketch.memo={}; game.save.poketch.memoErasing=false tap(100,100); local memo=game.save.poketch.memo check(next(memo)~=nil,'memo drawing accepts mouse click') -tap(112,152); tap(100,100) +-- memo_pad/main.c: the eraser (180-204, 24-88) and pencil (180-204, 104-168) buttons +tap(192,56); tap(100,100) check(next(memo)==nil,'memo eraser deletes touched pixels') -tap(112,152); tap(100,100); tap(176,152) -check(next(game.save.poketch.memo)==nil,'memo clear touch empties drawing') +tap(192,136); tap(100,100) +check(next(game.save.poketch.memo)~=nil,'memo pencil draws again') app('Dot Artist'); game.save.poketch.dots={} tap(20,20); tap(20,20) check(game.save.poketch.dots[0]==2,'dot artist repeated clicks cycle pixel shades') app('Stopwatch'); game.save.poketch.stopwatch=5; game.save.poketch.stopwatchRunning=false tap(112,112); check(game.save.poketch.stopwatchRunning,'stopwatch start text is clickable') -tap(112,144); check(game.save.poketch.stopwatch==0 and not game.save.poketch.stopwatchRunning,'stopwatch reset text is clickable') +-- stopwatch/main.c: tapping Voltorb again stops it; holding it 75 ticks starts the +-- reset sequence (90 ticks blinking, 60 exploding) that zeroes it +tap(112,112); check(not game.save.poketch.stopwatchRunning and game.save.poketch.stopwatch==5,'stopwatch tap stops it') +watch:touchpressed('mouse',112,112); for _=1,160 do watch:updatePresentation(1/60) end; watch:touchreleased('mouse') +for _=1,320 do watch:updatePresentation(1/60) end +check(game.save.poketch.stopwatch==0 and not game.save.poketch.stopwatchRunning,'held Voltorb resets the stopwatch') app('Kitchen Timer'); game.save.poketch.timerDuration=60; game.save.poketch.timerRunning=false -tap(88,104); tap(136,104) +game.save.poketch.timerFinished=false; game.save.poketch.timerPaused=false +-- kitchen_timer/main.c hit boxes: digit arrows above (y 80-96) and below, START +-- (16-80, 144-176), STOP (80-144) +tap(96,88); tap(144,88) check(game.save.poketch.timerDuration==121,'timer digits adjust minutes and seconds') -tap(112,144); check(game.save.poketch.timerRunning,'timer start label is clickable') -tap(112,144); check(not game.save.poketch.timerRunning,'timer stop label is clickable') +tap(48,160); check(game.save.poketch.timerRunning,'timer START is clickable') +tap(112,160); check(not game.save.poketch.timerRunning,'timer STOP is clickable') app('Alarm Clock'); game.save.poketch.alarmHour=0; game.save.poketch.alarmMinute=0; game.save.poketch.alarmEnabled=false -tap(88,112); tap(136,112); tap(112,152) -check(game.save.poketch.alarmHour==1 and game.save.poketch.alarmMinute==1 and game.save.poketch.alarmEnabled,'alarm digits and on/off text are clickable') +-- alarm_clock/main.c: hour/minute up arrows (64-80 / 112-128, y 112-128), the +-- switch's upper half (176-208, 72-104) sets the alarm +tap(72,120); tap(120,120); tap(192,88) +check(game.save.poketch.alarmHour==1 and game.save.poketch.alarmMinute==1 and game.save.poketch.alarmEnabled,'alarm arrows and switch are clickable') app('Move Tester'); game.save.poketch.moveTester={1,1,18} tap(28,128); tap(116,128); tap(108,40); tap(196,40); tap(108,72); tap(196,72) check(game.save.poketch.moveTester[1]==1 and game.save.poketch.moveTester[2]==1 @@ -334,13 +347,13 @@ check(game.save.poketch.color==7,'color slider responds to mouse dragging') local cries={}; package.loaded['src.core.Sound']={playCry=function(_,species) cries[#cries+1]=species end} app('Pokémon List'); tap(48,48) app('Pokémon History'); tap(32,32) -app('Friendship Checker'); tap(32,40) +app('Friendship Checker'); tap(48,52) check(#cries==3 and watch.friendshipTouch,'Pokemon icon apps accept clicks and give feedback') app('Calendar') local now=os.date('*t'); local first=((now.wday-1)-(now.day-1)%7)%7 local cell=first+now.day-1; local dateKey=('%04d-%02d-%02d'):format(now.year,now.month,now.day) game.save.poketch.calendar={} -tap(33+(cell%7)*20,43+math.floor(cell/7)*14) +tap(32+(cell%7)*24+8,40+math.floor(cell/7)*24+8) check(game.save.poketch.calendar[dateKey],'calendar date cell accepts click') app('Roulette'); tap(112,96) check(type(game.save.poketch.roulette)=='number','roulette accepts click') diff --git a/tools/gen4_sound_effects_check.lua b/tools/gen4_sound_effects_check.lua new file mode 100644 index 000000000..62a9617ad --- /dev/null +++ b/tools/gen4_sound_effects_check.lua @@ -0,0 +1,163 @@ +-- tools/gen4_sound_effects_check.lua +-- +-- PLATINUM SOUND EFFECTS, BY THE CARTRIDGE'S OWN NAMES. +-- +-- python tools/run_lua_check.py tools/gen4_sound_effects_check.lua [rom] [decomp] +-- +-- 1. Every sequence the ROM's SDAT SYMB block names resolves through +-- Sound.seDef by name and by id. +-- 2. With a pokeplatinum checkout (default C:/Users/cedri/AppData/Local/Temp/ppt), +-- every decomp `_sseq[_N]` constant (nitrosfx -naix numbering over +-- res/sound/pl_sound_data.json) resolves to the same sequence the decomp +-- builds it as -- SE_CONFIRM_sseq_3 is SEQ_SE_DP_SELECT, and so on. +-- 3. Every "SEQ_SE_*" literal in the rebuilt Platinum screens resolves, and +-- Sound.playSe reaches a NitroAudio player that starts and renders +-- non-silent PCM (the audio device is stubbed; the synth is real). +-- 4. SE player slots: two effects on a one-sequence SE player cut each other +-- off; an explicit handle lets one effect ring on several players. +package.path = './?.lua;' .. package.path + +local romPath = arg and arg[2] or 'Pokemon - Platinum Version (USA) (Rev 1).nds' +local decomp = arg and arg[3] or 'C:/Users/cedri/AppData/Local/Temp/ppt' + +local R = assert(require('src.import.NdsRom').open(romPath)) +local S = require('src.import.Gen4Sdat') +local raw = assert(R:read(S.PATH)); local arc = assert(S.open(raw)) +local audio = require('src.import.Gen4Audio').catalogue(arc) +audio.ndsArchive = 'test-platinum-sdat' +local data = { audio = audio } + +-- the audio device, stubbed: a queueable source that "plays" whatever it is +-- handed, and SoundData that measures what the synth wrote into it +local energy = 0 +local function queue() + local q = { playing = false, vol = 1 } + function q:setVolume(v) self.vol = v end + function q:getVolume() return self.vol end + function q:setFilter() end + function q:stop() self.playing = false end + function q:pause() self.playing = false end + function q:play() self.playing = true end + function q:isPlaying() return self.playing end + function q:getFreeBufferCount() return 0 end -- the check renders blocks itself + function q:queue() end + return q +end +love = { + filesystem = { read = function(path) if path == 'test-platinum-sdat' then return raw end end }, + audio = { newQueueableSource = queue }, + sound = { newSoundData = function() + return { setSample = function(_, _, _, v) energy = energy + math.abs(v) end } + end }, +} +package.loaded['src.render.Assets'] = { resolve = function(p) return p end, register = function() end } + +local Sound = require('src.core.Sound') +local checks, failures = 0, {} +local function check(ok, msg) checks = checks + 1; if not ok then failures[#failures + 1] = msg end end + +-- 1. the ROM's names and ids +local named = 0 +for i = 0, S.count(arc, S.SEQ) - 1 do + local name = S.name(arc, S.SEQ, i) + if name and S.sequence(arc, i) then + named = named + 1 + local a, b = Sound.seDef(data, name), Sound.seDef(data, i) + check(a and a.nds == i and b and b.nds == i, ('%s (%d) does not resolve'):format(name, i)) + end +end +check(named > 1000, 'SDAT SYMB names too few sequences: ' .. named) +print(('%d ROM sequences resolve by SYMB name and id'):format(named)) + +-- the SE players the INFO block declares +local se1 = S.player(arc, S.sequence(arc, Sound.seDef(data, 'SEQ_SE_DP_SELECT').nds).player) +check(se1 and se1.maxSequences == 1, 'SEQ_SE_DP_SELECT is not on a one-sequence SE player') +print(('SEQ_SE_DP_SELECT plays on %s (max %d sequence)'):format(tostring(se1 and se1.name), se1 and se1.maxSequences or -1)) + +-- 2. the decomp's file constants +local p = io.popen(('git -C "%s" show HEAD:res/sound/pl_sound_data.json 2>nul'):format(decomp)) +local json = p and p:read('*a') or '' +if p then p:close() end +if #json > 0 then + local section = json:match('"seqInfo"%s*:%s*(%b[])') or '' + local idx, hits, aliases = -1, {}, 0 + for entry in section:gmatch('%b{}') do + idx = idx + 1 + local file = entry:match('"fileName"%s*:%s*"([^"]+)"') + if file then + local n = hits[file] or 0; hits[file] = n + 1 + local alias = file:gsub('[^%w_]', '_') .. (n > 0 and ('_' .. n) or '') + local def = Sound.seDef(data, alias) + aliases = aliases + 1 + check(def and def.nds == idx, ('decomp %s should be sequence %d, resolves to %s') + :format(alias, idx, tostring(def and def.nds))) + end + end + check(aliases > 1000, 'decomp seqInfo parsed too few entries: ' .. aliases) + print(('%d decomp _sseq[_N] constants resolve to the decomp\'s own sequence ids'):format(aliases)) +else + print('decomp checkout not found; skipped the _sseq constant cross-check') +end +check((Sound.seDef(data, 'SE_CONFIRM_sseq_3') or {}).name == 'SEQ_SE_DP_SELECT', 'SE_CONFIRM_sseq_3 ~= SEQ_SE_DP_SELECT') + +-- 3. the screens +local SCREENS = { + 'Gen4ContestDance', 'Gen4ContestActing', 'Gen4ContestScreen', 'Gen4PoffinCooking', + 'Gen4PoffinCase', 'Gen4PoffinFeed', 'Gen4Poketch', 'Gen4EvolutionState', + 'Gen4MiningScreen', 'Gen4TrainerCard', 'Gen4BagMenu', 'Gen4SummaryMenu', + 'Gen4NamingScreen', 'Gen4Options', 'Gen4StartMenu', 'Gen4ShopMenu', + 'Gen4Pokedex', 'Gen4HallOfFame', +} +local used, perScreen = {}, {} +for _, screen in ipairs(SCREENS) do + local f = assert(io.open('src/ui/' .. screen .. '.lua', 'rb')) + local src = f:read('*a'); f:close() + local n = 0 + for name in src:gmatch('"(SEQ_SE_[%w_]+)"') do + if not used[name] then used[name] = screen end + n = n + 1 + end + perScreen[#perScreen + 1] = ('%s %d'):format(screen, n) +end +local names = {} +for name in pairs(used) do names[#names + 1] = name end +table.sort(names) +local audible = 0 +for _, name in ipairs(names) do + local def = Sound.seDef(data, name) + check(def ~= nil, ('%s (used by %s) is not a ROM sequence'):format(name, used[name])) + if def then + local src = Sound.playSe(data, name) + check(src and src:isPlaying(), name .. ': playSe did not start a player') + if src then + local before = energy + for _ = 1, 120 do + if not src.running then break end + src:render() + if energy > before then break end + end + check(energy > before, name .. ': rendered silence') + if energy > before then audible = audible + 1 end + src:stop() + end + end +end +print(('%d distinct SEQ_SE names in the screens (%s); %d render audible PCM') + :format(#names, table.concat(perScreen, ', '), audible)) + +-- 4. players +local a = Sound.playSe(data, 'SEQ_SE_DP_SELECT') +local b = Sound.playSe(data, 'SEQ_SE_DP_DECIDE') +local sameSlot = S.sequence(arc, Sound.seDef(data, 'SEQ_SE_DP_DECIDE').nds).player + == S.sequence(arc, Sound.seDef(data, 'SEQ_SE_DP_SELECT').nds).player +if sameSlot then + check(a and b and not a:isPlaying() and b:isPlaying(), 'a second effect on PLAYER_SE_x did not take the slot') +end +local h1 = Sound.playSe(data, 'SEQ_SE_DP_BADGE_C', { handle = 1, pitch = 1.5 }) +local h2 = Sound.playSe(data, 'SEQ_SE_DP_BADGE_C', { handle = 2 }) +check(h1 and h2 and h1 ~= h2 and h1:isPlaying() and h2:isPlaying(), 'handled effects did not ring together') +check(h1 and h1:getPitch() == 1.5 and Sound.isSePlaying(data, 'SEQ_SE_DP_BADGE_C', 1), 'handle pitch/state lost') +check(Sound.playSe(data, 'SEQ_SE_NOT_A_SOUND') == nil, 'an unknown name played something') + +if #failures > 0 then error(#failures .. ' of ' .. checks .. ' checks failed:\n ' .. table.concat(failures, '\n ')) end +print(checks .. ' sound effect checks passed') diff --git a/tools/gen4_sound_effects_render/conf.lua b/tools/gen4_sound_effects_render/conf.lua new file mode 100644 index 000000000..bca4add92 --- /dev/null +++ b/tools/gen4_sound_effects_render/conf.lua @@ -0,0 +1,5 @@ +function love.conf(t) + t.identity = "bt_gen4" + t.window = false + t.modules.graphics, t.modules.window = false, false +end diff --git a/tools/gen4_sound_effects_render/main.lua b/tools/gen4_sound_effects_render/main.lua new file mode 100644 index 000000000..c74e5002d --- /dev/null +++ b/tools/gen4_sound_effects_render/main.lua @@ -0,0 +1,95 @@ +-- tools/gen4_sound_effects_render/main.lua +-- +-- PLATINUM SOUND EFFECTS, RENDERED TO WAV through the real NitroAudio synth +-- (LOVE SoundData, the ROM's SBNK/SWAR instruments), reached the way a screen +-- reaches them: Sound.playSe by SYMB name or decomp constant. Each effect is +-- written to the LOVE save directory (bt_gen4/se_.wav) and must be +-- non-silent; then one is played on a real LOVE audio Source for a moment. +-- +-- "C:/Program Files/LOVE/love.exe" tools/gen4_sound_effects_render [rom] +-- (run from the repo root, which holds the Platinum ROM by default) + +local EFFECTS = { "SEQ_SE_DP_SELECT", "SE_CONFIRM_sseq_3", "SEQ_SE_DP_DECIDE", + "SEQ_SE_DP_CARD5", "SEQ_SE_DP_BADGE_C", "SEQ_SE_DP_CON_021", "SEQ_SE_DP_UG_002", + "SEQ_SE_DP_KIRAKIRA4", "SEQ_SE_DP_W025" } + +local live, elapsed, failed + +local function le(v, n) + local out = {} + for i = 1, n do out[i] = string.char(v % 256); v = math.floor(v / 256) end + return table.concat(out) +end + +local function wav(frames, rate) + local pcm = {} + for i, s in ipairs(frames) do + local v = math.floor(math.max(-1, math.min(1, s)) * 32767 + 0.5) + if v < 0 then v = v + 65536 end + pcm[i] = le(v, 2) + end + local body = table.concat(pcm) + return "RIFF" .. le(36 + #body, 4) .. "WAVEfmt " .. le(16, 4) .. le(1, 2) .. le(2, 2) + .. le(rate, 4) .. le(rate * 4, 4) .. le(4, 2) .. le(16, 2) .. "data" .. le(#body, 4) .. body +end + +function love.load(args) + package.path = love.filesystem.getWorkingDirectory() .. "/?.lua;" .. package.path + local ok, why = pcall(function() + local rom = args and args[1] or "Pokemon - Platinum Version (USA) (Rev 1).nds" + local R = assert(require("src.import.NdsRom").open(rom)) + local S = require("src.import.Gen4Sdat") + local raw = R:read(S.PATH); R:close() + local audio = require("src.import.Gen4Audio").catalogue(assert(S.open(raw))) + audio.ndsArchive = "test-platinum-sdat" + local read = love.filesystem.read + love.filesystem.read = function(path, ...) + if path == "test-platinum-sdat" then return raw end + return read(path, ...) + end + package.loaded["src.render.Assets"] = { resolve = function(p) return p end, register = function() end } + local Sound = require("src.core.Sound") + local data = { audio = audio } + for _, key in ipairs(EFFECTS) do + local src = assert(Sound.playSe(data, key), key .. ": playSe started nothing") + src:stop() + -- render it ourselves, block by block, to its end (or 4 s) + src.queue:stop(); src.running = true; src.ended = false; src.voices = {} + src.tracks = { [0] = { at = src.start, wait = 0, program = 0, volume = 127, expression = 127, + pan = 64, transpose = 0, bend = 0, bendRange = 2, noteWait = true, calls = {}, loops = {} } } + src.tickPhase = 1 + local frames, peak, sum = {}, 0, 0 + for _ = 1, 256 do + local block = src:render() + for i = 0, block:getSampleCount() - 1 do + local l, r = block:getSample(i, 1), block:getSample(i, 2) + frames[#frames + 1], frames[#frames + 2] = l, r + peak = math.max(peak, math.abs(l), math.abs(r)); sum = sum + l * l + r * r + end + if src.ended and #src.voices == 0 then break end + end + local seconds = #frames / 2 / 32768 + local rms = math.sqrt(sum / math.max(1, #frames)) + local file = ("se_%s.wav"):format(key) + love.filesystem.write(file, wav(frames, 32768)) + print(("%-22s -> %-20s %.2fs peak %.3f rms %.4f %s/%s"):format(key, Sound.seName(data, key), + seconds, peak, rms, love.filesystem.getSaveDirectory(), file)) + assert(peak > 0.01, key .. " rendered silence") + end + live = assert(Sound.playSe(data, "SEQ_SE_DP_CARD5")) + assert(live.queue:isPlaying(), "LOVE audio source failed to play the effect") + elapsed = 0 + end) + if not ok then print(why); failed = true; love.event.quit(1) end +end + +function love.update(dt) + if not live or failed then return end + elapsed = elapsed + dt + require("src.audio.NitroAudio").update() + if elapsed >= 0.5 then + print("SEQ_SE_DP_CARD5 sounded on a real LOVE queueable Source; " .. #EFFECTS .. " effects rendered non-silent") + live:stop() + love.event.quit(0) + end +end diff --git a/tools/gen4_touch_screen_parity_check.lua b/tools/gen4_touch_screen_parity_check.lua new file mode 100644 index 000000000..adb990b72 --- /dev/null +++ b/tools/gen4_touch_screen_parity_check.lua @@ -0,0 +1,197 @@ +-- tools/gen4_touch_screen_parity_check.lua -- the bottom screens of Platinum's +-- SUMMARY and BAG, and the bag's action menu / item moving, as logic (no LOVE): +-- +-- * summary (subscreen.c): the eight touch rectangles, the five-button +-- layout before the Contest Hall (FLAG_CONTEST_HALL_VISITED = 0x978) and +-- the contest pages hidden with it; a tap presses a button, sets its page, +-- holds while the stylus stays on it and leaves it lit; the pad clears it; +-- an egg's tap on SKILLS stays on its page; EXIT closes on release; the +-- condition graph grows over four frames from its centre corners +-- * bag (main.c): the pocket buttons' rectangles by pocket count, the dial +-- button as A, the dial's arc arithmetic (ApproximateArcLength) and its +-- 13-pixel steps, MakeItemActionsMenu per pocket, TRASH's count and +-- question, Item_MoveInPocket through SELECT +-- +-- python tools/run_lua_check.py tools/gen4_touch_screen_parity_check.lua + +package.path = "./?.lua;" .. package.path +package.loaded['src.render.Font'] = { width = function(s) return #tostring(s) * 6 end, + pushStyle = function() end, popStyle = function() end, draw = function() end } +package.loaded['src.core.Logger'] = { warn = function() end, info = function() end } +package.loaded['src.core.Strings'] = function(s, ...) if select('#', ...) > 0 then return s:format(...) end return s end +package.loaded['src.render.Assets'] = { image = function() return nil end } +package.loaded['src.ui.Theme'] = { cursor = 0 } +package.loaded['src.render.Gen4Palettes'] = { text = function() return nil end } +package.loaded['src.core.Sound'] = { play = function() end, playCry = function() end } +package.loaded['src.ui.SecondScreen'] = { mode = function() return "display" end, stowed = function() return false end, + draw = function() end, toLocal = function(_, x, y) return x, y end } + +local PASS, FAIL = 0, 0 +local function check(cond, what) + if cond then PASS = PASS + 1 else FAIL = FAIL + 1; print("FAIL: " .. what) end +end +local function keys(...) + local set = {} + for _, k in ipairs({ ... }) do set[k] = true end + return { wasPressed = function(_, k) return set[k] or false end, isDown = function() return false end } +end +local idle = keys() + +-- ------------------------------------------------------------- summary -- +do + local Summary = require('src.ui.Gen4SummaryMenu') + local closed = 0 + local function make(flags, mon) + mon = mon or { species = 392, level = 5, moves = {}, contest = { cool = 40, tough = 200 } } + local game = { data = { text = {} }, save = { party = { mon }, flags = flags }, input = idle, + stack = { pop = function() closed = closed + 1 end } } + return Summary.new(game, mon), game + end + local s, game = make({ FLAG_G4_0978 = true }) + check(#s:visiblePages() == 8, 'after the Contest Hall: eight pages') + check(s:subLayout() == Summary.SUB_LAYOUTS.normal, 'and the eight-button touch screen') + local r = Summary.SUB_LAYOUTS.normal.buttons + check(r[1].rect[1] == 32 and r[1].rect[4] == 47 and r[8].rect[3] == 208 and r[5].rect[3] == 136, + 'sSubscreenRectangles_Normal') + local n = make({}) + check(#n:visiblePages() == 5 and n:subLayout() == Summary.SUB_LAYOUTS.noContest + and Summary.SUB_LAYOUTS.noContest.scroll == 4, 'before it: five pages, five buttons, SUB_0 scrolled 4') + check(make({}, nil) and Summary.new({ data = { text = {} }, save = {}, input = idle, stack = {} }, + { mon = { species = 1, moves = {} }, showContest = true }):showContest(), 'a caller can still force showContest') + + -- tap SKILLS: pressed, page set, held, then lit + s:touchpressed('t', 60, 140) + check(s:pageKey() == 'skills' and s.subPress and s.subPress.index == 3, 'a tap presses SKILLS and sets its page') + for _ = 1, 10 do s:update() end + check(s.subPress ~= nil, 'the press holds while the stylus stays on the button') + s:touchreleased('t'); s:update() + check(s.subPress == nil and s.subLit == 3, 'released, the button stays lit') + game.input = keys('right'); s:update(); game.input = idle + check(s.subLit == nil and s:pageKey() == 'moves', 'the pad changes page and puts it out') + -- the pad is not read during a press + s:touchpressed('t', 220, 100) + game.input = keys('left'); s:update(); game.input = idle + check(s:pageKey() == 'ribbons', 'no pad input during a press') + s:touchreleased('t'); for _ = 1, 6 do s:update() end + -- EXIT closes on release + s:touchpressed('t', 228, 50); s:touchreleased('t') + for _ = 1, 6 do s:update() end + check(closed == 1, 'EXIT leaves once the stylus lets go') + -- an egg stays on MEMO + local egg = make({ FLAG_G4_0978 = true }, { species = 392, isEgg = true, egg = true, moves = {} }) + egg:touchpressed('t', 60, 140); egg:touchreleased('t') + for _ = 1, 6 do egg:update() end + check(egg:pageKey() == 'memo' and egg.subLit == nil, 'an egg cannot open SKILLS; the button goes back to rest') + -- the condition graph grows + local g = make({ FLAG_G4_0978 = true }) + for i, p in ipairs(g.pages) do if p.key == 'condition' then g.page = i end end + g:update() + local q0 = g:conditionQuads(g.graphFrame) + check(g.graphFrame == 0 and q0[1][1] == q0[1][7] and q0[1][2] == q0[1][8], 'frame 0: each quad on its centre corner') + for _ = 1, 4 do g:update() end + local full = g:conditionQuads(nil) + local now = g:conditionQuads(g.graphFrame) + check(g.graphFrame == 4 and now[1][1] == full[1][1] and now[2][3] == full[2][3], 'frame 4: grown') + check(#g:conditionFlashes() == 1, 'one condition flash, on the highest stat') +end + +-- ----------------------------------------------------------------- bag -- +do + local Bag = require('src.ui.Gen4BagMenu') + local items = { + [1] = { id = 1, name = 'A', fieldPocket = 0, fieldUseFunc = 1 }, + [2] = { id = 2, name = 'B', fieldPocket = 0 }, + [3] = { id = 3, name = 'C', fieldPocket = 0 }, + [4] = { id = 4, name = 'D', fieldPocket = 0, preventToss = true }, + [5] = { id = 450, name = 'Bike', fieldPocket = 7, fieldUseFunc = 5, preventToss = true, canRegister = true }, + [6] = { id = 6, name = 'TM', fieldPocket = 3, fieldUseFunc = 3 }, + [7] = { id = 7, name = 'Berry', fieldPocket = 4, fieldUseFunc = 8 }, + } + local save = { inventory = { [1] = 3, [2] = 1, [3] = 9, [4] = 1, [5] = 1, [6] = 1, [7] = 2 }, + bagOrder = { 1, 2, 3, 4, 5, 6, 7 }, player = {} } + local game = { data = { items = items, text = {} }, save = save, input = idle, + stack = { pop = function() end, push = function() end } } + local b = Bag.new(game, {}) + check(b:pocketButtonAt(30, 100) == 2 and b:pocketButtonAt(150, 170) == 5 and b:pocketButtonAt(128, 80) == nil, + 'sPocketButtonTouchRectangles_8Pockets') + check(Bag.BUTTON_RECTS[4][2][1] == 120 and Bag.BUTTON_RECTS[1][1][3] == 136, 'the 4- and 1-pocket rectangles') + check(b:dialButtonAt(104, 56) and b:dialButtonAt(151, 103) and not b:dialButtonAt(152, 80), 'sDialBtnTouchRect') + -- the arc: a quarter of the way round clockwise from the top is positive + check(Bag.arcLength(0, 80, -10, 79) > 0 and Bag.arcLength(0, 80, 10, 79) < 0, 'ApproximateArcLength signs the turn') + -- the action menus + local function actions(id) return table.concat(b:itemActions(id), ',') end + check(actions(1) == 'use,give,trash,cancel', 'ITEMS: USE GIVE TRASH CANCEL') + check(actions(2) == 'give,trash,cancel', 'no field function: no USE') + check(actions(4) == 'cancel', 'too important: no GIVE / TRASH') + b.pocket = 8 + check(actions(5) == 'use,register,cancel', 'a registrable key item') + save.onBike = true; save.registeredItem = 5 + check(actions(5) == 'walk,deselect,cancel', 'on the bike: WALK; registered: DESELECT') + save.onBike, save.registeredItem = nil, nil + b.pocket = 4 + check(actions(6) == 'use,give,cancel', 'TMs: no TRASH') + b.pocket = 5 + check(actions(7) == 'checkTag,use,give,trash,cancel', 'berries: CHECK TAG first') + -- A opens it, B shuts it + b.pocket = 1; b.listPos, b.cursorPos = 0, 1; b:rebuild() + game.input = keys('a'); b:update(); game.input = idle + check(b.menu and b.menu.kind == 'actions' and b.menu.id == 1, 'A on an item opens its actions') + game.input = keys('b'); b:update(); game.input = idle + check(b.menu == nil, 'B closes them') + -- TRASH: how many, then the question, then the message + b:openActions(b:selected()) + for i, a in ipairs(b.menu.rows) do if a == 'trash' then b.menu.index = i end end + game.input = keys('a'); b:update() + check(b.trash and b.trash.stage == 'count' and b.trash.n == 1, 'TRASH asks how many') + game.input = keys('up'); b:update(); b:update() + game.input = keys('down'); b:update(); b:update(); b:update() + check(b.trash.n == 3, 'up / down step and wrap (sub_0208C15C)') + game.input = keys('a'); b:update() + check(b.menu and b.menu.kind == 'yesno', 'then YES / NO') + b:update(); game.input = idle + check(save.inventory[1] == nil and b.message ~= nil, 'YES throws them away and says so') + game.input = keys('a'); b:update(); game.input = idle + check(b.message == nil, 'A puts the message away') + -- moving: SELECT on B, two down, A -> B goes after C + save.inventory[1] = 3; save.bagOrder = { 1, 2, 3, 4, 5, 6, 7 } + b.listPos, b.cursorPos = 0, 2; b:rebuild() + game.input = keys('select'); b:update() + check(b.moving and b.moving.id == 2, 'SELECT picks the item up') + game.input = keys('down'); b:update(); b:update() + game.input = keys('a'); b:update(); game.input = idle + check(b.moving == nil and table.concat(save.bagOrder, ',') == '1,3,2,4,5,6,7', 'Item_MoveInPocket puts it before the bar') + check(b:selected().id == 2, 'and the cursor follows it') + b.pocket = 4; b:rebuild() + check(not b:canMove(), 'TMs cannot be moved') + -- touch: a pocket button changes pocket and lights; the dial button is A + b.pocket = 1; b:rebuild() + b:touchpressed('t', 30, 100) + check(b.pocket == 2 and b.pocketPress, 'a pocket button changes pocket') + for _ = 1, 3 do b:update() end + check(b:pocketButtonFace(2) == 2 and b.shock, 'pressed face and the shockwave on frame 3') + b:touchreleased('t'); for _ = 1, 5 do b:update() end + check(b:pocketButtonFace(2) == 1, 'lit once released') + game.input = keys('left'); b:update(); game.input = idle + check(b:pocketButtonFace(2) == 0 and b.pocket == 1, 'the pad puts it out') + b.listPos, b.cursorPos = 0, 1; b:rebuild() + b:touchpressed('t', 128, 80); b:update(); b:touchreleased('t') + check(b.menu and b.menu.kind == 'actions' and b:dialButtonFace() == 2, 'the dial button is A') + b.menu = nil + -- the dial: anticlockwise drag scrolls the list down, 13 pixels a step + save.inventory = {}; save.bagOrder = {} + for i = 1, 3 do save.inventory[i] = 1; save.bagOrder[#save.bagOrder + 1] = i end + for i = 10, 30 do items[i] = { id = i, name = 'X' .. i, fieldPocket = 0 }; save.inventory[i] = 1; save.bagOrder[#save.bagOrder + 1] = i end + b.listPos, b.cursorPos = 0, 1; b:rebuild() + b:touchpressed('t', 128, 30) + for k = 1, 16 do + local a = math.rad(-90 - k * 90 / 16) + b:touchmoved('t', 128 + 50 * math.cos(a), 80 + 50 * math.sin(a)); b:update() + end + for _ = 1, 8 do b:update() end + check(b.index > 3 and b.dialRotation and b.dialRotation > 180, 'turning the dial scrolls and turns it') + b:touchreleased('t'); b:update() + check(b.dial == nil, 'letting go stops the dial') +end + +print(('%d checks, %d failed'):format(PASS, FAIL)) +if FAIL > 0 then error('touch screen parity checks failed') end diff --git a/tools/gen4_town_map_harness/conf.lua b/tools/gen4_town_map_harness/conf.lua new file mode 100644 index 000000000..9a1c62227 --- /dev/null +++ b/tools/gen4_town_map_harness/conf.lua @@ -0,0 +1,5 @@ +function love.conf(t) + t.window.width, t.window.height = 400, 300 + t.identity = "bt_gen4" + t.console = false +end diff --git a/tools/gen4_town_map_harness/main.lua b/tools/gen4_town_map_harness/main.lua new file mode 100644 index 000000000..334e6e932 --- /dev/null +++ b/tools/gen4_town_map_harness/main.lua @@ -0,0 +1,135 @@ +-- tools/gen4_town_map_harness/main.lua +-- +-- PLATINUM'S TOWN MAP (src/ui/Gen4TownMap.lua), rendered into town_map.png +-- (or $POKEPORT_OUT): the Town Map key item on Twinleaf, on Jubilife and on a +-- route; Fly mode with every town visited, the cursor on a town (both blink +-- phases) and on a route; a save with nothing visited; and the hidden +-- locations unlocked. +-- +-- POKEPORT_DATA_DIR=/data/generated \ +-- POKEPORT_ASSET_ROOT= love tools/gen4_town_map_harness + +function love.load() + local ok, err = xpcall(function() + require("src.core.GameVersion").set("platinum") + local assetRoot = os.getenv("POKEPORT_ASSET_ROOT") + if assetRoot then + local Assets = require("src.render.Assets") + local realImage, held = Assets.image, {} + Assets.image = function(path, ...) + if type(path) == "string" and not held[path] then + local f = io.open(assetRoot .. "/" .. path, "rb") + if f then + local bytes = f:read("*a"); f:close() + local okI, img = pcall(love.graphics.newImage, love.filesystem.newFileData(bytes, path)) + if okI then img:setFilter("nearest", "nearest"); held[path] = img end + end + end + return held[path] or realImage(path, ...) + end + end + local Data = require("src.core.Data") + Data:load() + require("src.render.Font").load(Data) + pcall(function() require("src.ui.Theme").load(Data) end) + local T = require("src.ui.Gen4TownMap") + local function game(visited, extra) + local flags = {} + if visited then for fa = 0, 40 do flags[("FLAG_G4_%04X"):format(0x9B1 + fa)] = true end end + local save = { flags = flags, player = { gender = "boy" }, gen4TownMapCell = { x = 3, z = 27 }, vars = {} } + for k, v in pairs(extra or {}) do save[k] = v end + return { data = Data, save = save, stack = { pop = function() end }, + input = { wasPressed = function() return false end, isDown = function() return false end } } + end + local scenes = { + { "map Twinleaf", game(true), "map", 3, 27, 0 }, + { "map Jubilife", game(true), "map", 4, 23, 0 }, + { "map route", game(true), "map", 5, 20, 0 }, + { "fly town", game(true), "fly", 4, 23, 0 }, + { "fly town blink", game(true), "fly", 4, 23, 40 }, + { "fly route", game(true), "fly", 5, 20, 0 }, + { "fly nothing visited", game(false), "fly", 4, 23, 0 }, + { "hidden unlocked", game(true, { gen4HiddenPaths = { 0, 1, 2, 3 } }), "map", 26, 9, 0 }, + } + local pages = {} + for _, sc in ipairs(scenes) do + local m = T.new(sc[2], { mode = sc[3] }) + if sc[1] == "hidden unlocked" then + local H = require("src.world.Gen4HiddenPaths") + for i = 0, 3 do pcall(H.set, sc[2].save, i, true) end + end + m.cx, m.cz = sc[4], sc[5] + m.frame = sc[6] + local cv = love.graphics.newCanvas(256, 192) + love.graphics.setCanvas(cv); love.graphics.clear(0, 0, 0, 1); m:draw(); love.graphics.setCanvas() + pages[#pages + 1] = cv + if m.drawBottom then + local b = love.graphics.newCanvas(256, 192) + love.graphics.setCanvas(b); love.graphics.clear(0, 0, 0, 1); m:drawBottom(); love.graphics.setCanvas() + pages[#pages + 1] = b + end + end + -- the bottom screen's other states, each top screen beside its touch + -- screen: the zoom button pressed, the fade, the zoomed map on a town + -- (MAP signpost), a route (ARROW signpost), a landmark, mid-slide, and + -- the location history (the dot cycling) + local function both(m) + local a = love.graphics.newCanvas(256, 192) + love.graphics.setCanvas(a); love.graphics.clear(0, 0, 0, 1); m:draw(); love.graphics.setCanvas() + local b = love.graphics.newCanvas(256, 192) + love.graphics.setCanvas(b); love.graphics.clear(0, 0, 0, 1); m:drawBottom(); love.graphics.setCanvas() + pages[#pages + 1] = a; pages[#pages + 1] = b + end + local function zoomedAt(x, z, g) + local m = T.new(g or game(true), { mode = "map" }) + m.cx, m.cz = x, z + m.zx, m.zy = x * 16 + 8 - 112, z * 16 - 96 + m.zoomed = true + m:stepBottom() + return m + end + do + local m = T.new(game(true), { mode = "map" }) + m:bottomTouch(128, 108); for _ = 1, 5 do m:update() end + print("zoom pressed", m:zoomButtonState()); both(m) + for _ = 1, 9 do m:update() end + print("fading", m:bottomFade()); both(m) + for _ = 1, 30 do m:update() end + print("zoomed", m.zoomed); both(m) + end + both(zoomedAt(4, 23)) -- Jubilife: MAP signpost + both(zoomedAt(5, 20)) -- a route: ARROW signpost + both(zoomedAt(12, 16)) -- Mt. Coronet + both(zoomedAt(17, 22)) + do + local g = game(true) + local TM = require("src.ui.Gen4TownMap") + for _, c in ipairs({ { 3, 27 }, { 3, 26 }, { 4, 25 }, { 4, 23 }, { 7, 22 }, { 9, 22 } }) do TM.pushHistory(g.save, c[1], c[2], 1) end + g.save.gen4TownMapCell = { x = 9, z = 22 } + local m = T.new(g, { mode = "map" }) + print("history", #m.history) + m.frame = 0; both(m) + m.frame = 52 * 2; both(m) + end + local perRow = 4 + local rows = math.ceil(#pages / perRow) + local sheet = love.graphics.newCanvas(perRow * 260, rows * 196) + love.graphics.setCanvas(sheet); love.graphics.clear(1, 0, 1, 1) + for i, p in ipairs(pages) do + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(p, ((i - 1) % perRow) * 260, math.floor((i - 1) / perRow) * 196) + end + love.graphics.setCanvas() + local png = sheet:newImageData():encode("png") + local out = os.getenv("POKEPORT_OUT") + if out then + local f = assert(io.open(out .. "/town_map.png", "wb")); f:write(png:getString()); f:close() + print("saved to " .. out .. "/town_map.png") + else + love.filesystem.write("town_map.png", png) + print("saved to " .. love.filesystem.getSaveDirectory()) + end + end, debug.traceback) + if not ok then print(err) end + love.event.quit() +end diff --git a/tools/gen5_import_check.lua b/tools/gen5_import_check.lua new file mode 100644 index 000000000..d5f1eef55 --- /dev/null +++ b/tools/gen5_import_check.lua @@ -0,0 +1,478 @@ +-- Run: python tools/run_lua_check.py tools/gen5_import_check.lua [white .nds] +-- +-- BLACK AND WHITE THROUGH src/import/RomExtractorGen5.lua, against values +-- that are not in question: the record layouts in src/import/Gen5Data.lua are +-- the community's (pokeblack has not decompiled the readers yet), so this is +-- where a wrong offset gets caught -- Bulbasaur's stats, types and abilities, +-- Snivy's evolution, Pound and Tackle, the Master Ball and a Potion, the +-- Striaton triplets' teams, and the text decryption (both the plain and the +-- 9-bit compressed strings). Writes are captured, not cached. + +package.path = './?.lua;./?/init.lua;' .. package.path +love = require('tests.love_stub') +local PASS, FAIL = 0, 0 +local function check(c, l) if c then PASS = PASS + 1 else FAIL = FAIL + 1; print('FAIL: ' .. l) end end +local black = arg and arg[1] or './Pokemon - Black Version (USA, Europe) (NDSi Enhanced).nds' +local white = arg and arg[2] or './Pokemon - White Version (USA, Europe) (NDSi Enhanced).nds' + +local LuaWriter = require('src.import.LuaWriter') +local captured +LuaWriter.write = function(path, value) captured[path:match('([^/]+)%.lua$')] = value end +-- nothing reaches the disk: the field stage writes binaries and PNGs +require('src.import.CacheFs').write = function() return true end +local IW = require('src.import.ImageWriter') +IW.save = function() return true end +IW.fromRGBA8 = function(w, h) return { width = w, height = h } end +local X = require('src.import.RomExtractorGen5') + +local function extract(path, version) + captured = {} + local x, err = X.new(path, version) + if not x then print('cannot open ' .. tostring(path) .. ': ' .. tostring(err)) return nil end + local ok, e = pcall(x.run, x) + if not ok then print('extract failed: ' .. tostring(e)) end + x:close() + if ok then captured._x = x end + return ok and captured or nil +end + +local b = extract(black, 'black') +check(b ~= nil, 'Black: the stages run') +if b then + local mons = b.pokemon or {} + local bulba = mons[1] or {} + local s = bulba.baseStats or {} + check(bulba.name == 'Bulbasaur' and s.hp == 45 and s.attack == 49 and s.defense == 49 and s.speed == 45 + and s.spatk == 65 and s.spdef == 65, 'Bulbasaur: 45/49/49/45/65/65') + check(bulba.types and bulba.types[1] == 'GRASS' and bulba.types[2] == 'POISON', '...Grass/Poison') + check(bulba.abilityIds and bulba.abilityIds[1] == 65 and bulba.abilityIds[3] == 34, '...Overgrow (65), hidden Chlorophyll (34)') + check(bulba.catchRate == 45 and bulba.growthRate == 'MEDIUM_SLOW' and bulba.genderRatio == 31, '...catch rate 45, medium slow, 7:1') + local snivy = mons[495] or {} + check(snivy.name == 'Snivy' and snivy.evolutions and snivy.evolutions[1] and snivy.evolutions[1].species == 496 + and snivy.evolutions[1].param == 17, 'Snivy evolves into Servine (496) at 17') + check(snivy.learnset and snivy.learnset[1] and snivy.learnset[1].move == 33, "Snivy's first move is Tackle (33)") + check(mons[649] and mons[649].name == 'Genesect', 'all 649, Genesect last') + local moves = b.moves or {} + local pound = moves[1] or {} + check(pound.name == 'Pound' and pound.type == 'NORMAL' and pound.class == 'physical' and pound.power == 40 + and pound.accuracy == 100 and pound.pp == 35, 'Pound: Normal, physical, 40, 100, 35 PP') + local qa = moves[98] or {} + check(qa.name == 'Quick Attack' and qa.priority == 1, 'Quick Attack: priority +1') + check(moves[557] and moves[557].name == 'V-create' and moves[559] and moves[559].name == 'Fusion Bolt', + 'V-create (557) to Fusion Bolt (559)') + local items = b.items or {} + check(items[1] and items[1].name == 'Master Ball' and items[1].price == 0, 'the Master Ball, price 0') + check(items[17] and items[17].name == 'Potion' and items[17].price == 300 and items[2].price == 1200, + 'prices stored in tens: a Potion 300, an Ultra Ball 1200') + check(items[1].bwPocket == 0 and items[17].bwPocket == 1 and items[328] and items[328].bwPocket == 2 + and items[149] and items[149].bwPocket == 3 and items[621] and items[621].bwPocket == 4, + 'pockets (bits 7..10 at 0x08): Master Ball 0, Potion 1, TM01 2, Cheri 3, Xtransceiver 4') + local trainers = b.trainers or {} + local leaders = {} + for _, t in pairs(trainers) do + if t.className == 'Leader' and (t.name == 'Cilan' or t.name == 'Chili' or t.name == 'Cress') then leaders[#leaders + 1] = t end + end + local okLeaders = #leaders >= 3 + for _, t in ipairs(leaders) do + local p = t.party or {} + if #p ~= 2 or (p[1].species ~= 506 and p[1].species ~= 510) then okLeaders = false end + end + check(okLeaders, 'the Striaton triplets: Leaders, each opening with Lillipup (506) or Purrloin (510)') + local names = b.gen5_text and b.gen5_text.system and b.gen5_text.system[190] + check(names and names[1] == 'Jimmy', 'the compressed trainer names decode (bank 190)') + check(b.text and b.text['TEXT_G5S_0070_00001'] == 'Bulbasaur', 'the flat label TEXT_G5S_0070_00001') + -- THE FIELD (Gen5Maps / Gen5Field), drawn by the Gen 4 renderer + local zones = b.gen5_zones or {} + local nz = 0 for _ in pairs(zones) do nz = nz + 1 end + check(nz == 427 and zones[6] and zones[6].matrix == 0 and zones[317] and zones[317].encounters == 77, + '427 zones: Striaton (6) and Route 1 (317) on matrix 0, Route 1 on encounter table 77') + local terrain = b.gen4_terrain or {} + local nc, nr = 0, 0 + for _ in pairs(terrain.chunks or {}) do nc = nc + 1 end + for _ in pairs(terrain.refused or {}) do nr = nr + 1 end + check(nc == 649 and nr == 0, 'all 649 map chunks (WB, GC, NG, RD) pack through the Gen 4 model pipeline') + local grid = terrain.matrices and terrain.matrices[0] + check(grid and grid.width == 29 and grid.height == 27, "matrix 0 is Unova's 29 x 27 overworld") + local striaton = b.maps and b.maps.Z006 + local st = terrain.maps and terrain.maps.Z006 + local stSet = st and terrain.sets[st.texture] + check(striaton and striaton.label == 'Striaton City' and striaton.width == 96 and st + and (st.texture == 6 or (stSet and stSet.merged and stSet.merged[1] == 6)), + "Striaton City: 96 tiles wide, wearing texture set 6 (its area) first") + -- buildings: the area's pack (Gen5Field.packFor), models by building id + local fr = b._x and b._x.fieldReport or {} + local F5 = require('src.import.Gen5Field') + local k6, p6 = F5.packFor(6) + local k217, p217 = F5.packFor(217) + check(k6 == 'outdoor' and p6 == 1 and k217 == 'indoor' and p217 == 7, + 'building packs: Striaton (area 6) outdoor pack 1, area 217 indoor pack 7') + check((fr.buildingModels or 0) > 4000 and (fr.placementsFound or 0) > 3900 + and (fr.placementsFound or 0) / ((fr.placementsFound or 0) + (fr.placementsMissing or 1)) > 0.85, + ('%s building models; %s of %s placements resolve in their pack'):format(tostring(fr.buildingModels), + tostring(fr.placementsFound), tostring((fr.placementsFound or 0) + (fr.placementsMissing or 0)))) + local s6 = b.gen4_models and b.gen4_models.sets and b.gen4_models.sets.bwOutdoor + local striatonPack = s6 and s6.packs and s6.packs[1] + check(striatonPack and striatonPack.models[0 + 1] and striatonPack.models[152 + 1], + "Striaton's placed ids 0 and 152 are models in outdoor pack 1") + -- a map whose box takes in a neighbour's chunks wears both sets + local r1t = terrain.maps and terrain.maps.Z317 + check(r1t and terrain.sets[r1t.texture] and terrain.sets[r1t.texture].merged + and terrain.sets[r1t.texture].merged[1] == 2, + "Route 1 wears a merged set, its own (2) first") + -- events and people (Gen5Events, Gen5Overworld) + local st6 = b.maps and b.maps.Z006 + local realWarps = 0 + for _, w in ipairs(st6 and st6.warps or {}) do if not w.extentOf then realWarps = realWarps + 1 end end + check(st6 and #st6.objects == 15 and realWarps == 7 and #st6.signs == 7 and #st6.coordEvents == 2, + "Striaton's events: 15 NPCs, 7 warps, 7 signs, 2 triggers") + check(st6 and st6.objects[1].x == 772 - st6.originX and st6.warps[1].destMap == 'Z008', + 'NPC positions taken back to the map; the first door leads to zone 8') + check((fr.npcsWithSprite or 0) / math.max(fr.npcs or 1, 1) > 0.95 and (fr.sprites or 0) > 700, + ('%s sprite sheets from /a/0/4/9; %s of %s NPCs have one'):format(tostring(fr.sprites), + tostring(fr.npcsWithSprite), tostring(fr.npcs))) + -- a new game starts in the player's room (zone 391's header spot), the + -- first blackout point the floor below + local boot = b.field and b.field.boot + local z391 = (b.gen5_zones or {})[391] or {} + check(boot and boot.startMap == 'Z391' and boot.startX == 5 and boot.startY == 6 and z391.spawnX == 5 + and boot.lastHeal and boot.lastHeal.map == 'Z390' and boot.screens and boot.screens.newGame == false, + ('new game: %s (%s,%s), first blackout %s'):format(tostring(boot and boot.startMap), tostring(boot and boot.startX), + tostring(boot and boot.startY), tostring(boot and boot.lastHeal and boot.lastHeal.map))) + -- placements' z is stored negated: Nuvema's living room (zone 390, land + -- 504) has its floor at z -250..-72 and its 16 pieces inside it + local liv = ((b.gen4_terrain or {}).chunks or {})[504] + local inside = liv and #liv.objects == 16 + for _, o in ipairs((liv and liv.objects) or {}) do if not (o.z <= -72 and o.z >= -250) then inside = false end end + check(inside, "Nuvema's living-room furniture stands on its own floor (placement z negated)") + local cheren = b.sprites and b.sprites.SPRITE_G4_012 + check(cheren ~= nil, "Cheren's sheet (member 12) is written, colour 0 clear like every character's") + local hero = b.field and b.field.playerSprites and b.sprites and b.sprites[b.field.playerSprites.walk] + check(hero and hero.frames == 32 and hero.facings and hero.facings.down[1] == 3, + "the player (sprite id 1): 32 frames, three a direction, down from frame 3") + -- scripts: widths from the cartridge's own handlers (Gen5ScriptOps) + local sc = b.gen5_scripts or {} + local w = sc.widths or {} + check(sc.offset == 15 and w[0x02] == 0 and w[0x05] == 0 and w[0x04] == 4 and w[0x1E] == 4 and w[0x1F] == 5, + 'script commands: End/Return 0, Call/Jump a u32, If a u8 + u32 (table slot = opcode + 15)') + check(w[0x3D] == 8 and w[0xA6] == 2 and w[0x2E] == 0 and w[0x74] == 0, + "...and a Striaton conversation's own: message 8 bytes, sound 2, lock and face-player none") + local rep = sc.report or {} + check((rep.clean or 0) / math.max(rep.entries or 1, 1) > 0.95 and (rep.secondBestOffset or 1e9) < (rep.clean or 0) / 2, + ('%s of %s script entries decode to an end; the alignment is unambiguous'):format(tostring(rep.clean), tostring(rep.entries))) + check(sc.widths and sc.widths[0x38] == 3 and sc.widths[0x2E] == 0 and (rep.clean or 0) >= 3340, + ('tail-jump stubs followed: 0x38 is 3 bytes, lockall still 0; %s clean entries'):format(tostring(rep.clean))) + -- the script pool the Gen 4 engine runs (Gen5ScriptLink) + local lr = b._x and b._x.linkReport or {} + local ms = b.map_scripts or {} + local label = ms.maps and ms.maps.Z006 and ms.maps.Z006.objects and ms.maps.Z006.objects[1] + local talk = label and ms.scripts[label] + local names = {} + for _, ins in ipairs(talk and talk.instructions or {}) do names[#names + 1] = ins.name end + local joined = table.concat(names, ',') + check(joined:find('lockall,playse,faceplayer,message', 1, true) and joined:find('waitabpress,closemessage', 1, true) + and talk.instructions[4].args[1] == 9, + "Striaton's first NPC: lock, sound, face, message 9, wait, close ... (" .. joined:sub(1, 80) .. ")") + check((lr.linked or 0) > 1400 and (lr.named or 0) / math.max(lr.instructions or 1, 1) > 0.5, + ('%s NPCs and signs linked; %s of %s instructions named'):format(tostring(lr.linked), tostring(lr.named), tostring(lr.instructions))) + check(b.text and b.text['TEXT_B' .. ('%04d'):format(zones[6].messages) .. '_00009'], "the story line answers to the Gen 4 label") + -- BW's stack conditionals, rewritten into Platinum's compare + gotoif + local Link = require('src.import.Gen5ScriptLink') + local function mk(op, hex) local s = '' for byte in hex:gmatch('%x%x') do s = s .. string.char(tonumber(byte, 16)) end return { op = op, operands = s, at = 1, width = #s } end + local flagIsClear = Link.rewriteStack({ mk(0x10, '4e01'), mk(0x08, '0000'), mk(0x11, '0100'), mk(0x1F, 'ff10000000') }) + local varAtLeast = Link.rewriteStack({ mk(0x09, '2180'), mk(0x08, '1027'), mk(0x11, '0400'), mk(0x1F, 'ff9d000000') }) + local Cmd = require('src.script.Gen4Commands') + local function jumps(cond, result) return Cmd.holds({ g4Compare = result }, cond) end + check(flagIsClear[1].name == 'checkflag' and flagIsClear[1].args[1] == 0x14E and flagIsClear[2].name == 'gotoif' + and jumps(flagIsClear[2].args[1], 1) and not jumps(flagIsClear[2].args[1], 0), + 'push flag 0x14E, push 0, ==, 0x1F 0xFF (jump if false): jumps when the flag is set, not when clear') + check(varAtLeast[1].name == 'comparevartovalue' and varAtLeast[1].args[1] == 0x8021 and varAtLeast[1].args[2] == 10000 + and varAtLeast[2].args[1] == 0, + 'push var 0x8021, push 10000, >=, jump if false: compare the var and jump on <') + local lr2 = b._x and b._x.linkReport or {} + check((lr2.named or 0) / math.max(lr2.instructions or 1, 1) > 0.7, + ('%s of %s script instructions named once the conditionals are lowered'):format(tostring(lr2.named), tostring(lr2.instructions))) + -- scripted movement: 0x64's lists are Gen 4's, decoded into the instruction + local moved, playerMoves = 0, 0 + for _, sc2 in pairs(ms.scripts or {}) do + for _, ins in ipairs(sc2.instructions) do + if ins.name == 'applymovement' and ins.movement and #ins.movement > 0 then + moved = moved + 1 + if ins.args[1] == 0xFF then playerMoves = playerMoves + 1 end + end + end + end + check((lr2.movements or 0) > 3000 and moved > 3000 and playerMoves > 500, + ('%s movement lists decoded (%d reached, %d move the player)'):format(tostring(lr2.movements), moved, playerMoves)) + -- the script id table and the shared files it names + local rows = b.gen5_version and b.gen5_version.scriptBands or {} + local common + for _, row in ipairs(rows) do if row[1] == 2000 then common = row end end + check(#rows == 46 and common and common[3] == 854 and common[4] == 158, + ('the script id table: %d bands, 2000 -> member 854, text bank 158'):format(#rows)) + local cb = ms.bands and ms.bands.common_scripts + check(cb and cb.member == 854 and #cb.entries >= 5 and ms.scripts[cb.entries[1]], 'the common scripts (member 854) are in the pool by entry') + -- a shared file's `end` returns to its caller (0x1C runs it in a context + -- of its own): the give-item script's closing `end`, member 862 + local gi = ms.scripts['M0862/S0130'] + local last = gi and gi.instructions[#gi.instructions] + check(last and last.name == 'return', "a shared file's end is a return (give-item, member 862: " .. tostring(last and last.name) .. ')') + -- a common-script call: save 0x8000/0x8001, set them, call, restore + local Link2 = require('src.import.Gen5ScriptLink') + local saved = Link2.rewritePushPop(Link2.rewriteStack({ mk(0x09, '0080'), mk(0x09, '0180'), mk(0x2A, '00800c00'), + mk(0x1C, '7e27'), mk(0x0A, '0180'), mk(0x0A, '0080') })) + local seq = {} for _, ins in ipairs(saved) do seq[#seq + 1] = ins.name end + check(table.concat(seq, ',') == 'setvarfromvar,setvarfromvar,setvarfromvalue,callcommonscript,setvarfromvar,setvarfromvar' + and saved[1].args[1] == 0x8100 and saved[2].args[1] == 0x8101 and saved[5].args[1] == 0x8001 and saved[5].args[2] == 0x8101 + and saved[6].args[2] == 0x8100 and saved[4].args[1] == 0x277E, + 'push/pop around a common-script call become copies into slot vars and back (' .. table.concat(seq, ',') .. ')') + -- a compound condition: var 0x408A == 3 AND flag 0x80 == 0 + local both = Link2.rewriteStack({ mk(0x09, '8a40'), mk(0x08, '0300'), mk(0x11, '0100'), mk(0x10, '8000'), + mk(0x08, '0000'), mk(0x11, '0100'), mk(0x11, '0700'), (function() local j = mk(0x1F, 'ff1c000000'); j.target = 99; return j end)() }) + check(#both == 4 and both[1].name == 'comparevartovalue' and both[2].args[1] == 5 and both[2].target == 99 and both[4].target == 99 and both[3].name == 'checkflag' + and jumps(both[4].args[1], 1) and not jumps(both[4].args[1], 0), + 'NOT (var == 3 AND flag clear): jump when the var is not 3, or when the flag is set') + -- yes/no: 0x47 is Platinum's showyesnomenu, YES = 0 in both + local yn = Link2.name(mk(0x47, '1080')) + check(yn.name == 'showyesnomenu' and yn.args[1] == 0x8010, '0x47 is the yes/no box into its var') + -- the party menu and the Egg check expand into Platinum's rows + local function mkw(op, hex, at) local i = mk(op, hex); i.at = at; return i end + local party = Link2.rewriteCompound({ Link2.name(mkw(0x107, '228021800000', 11)), Link2.name(mkw(0x102, '20802180', 19)) }) + local pn = {} for _, ins in ipairs(party) do pn[#pn + 1] = ins.name end + check(table.concat(pn, ',') == 'openpartymenufortrade,getselectedpartyslot,setvarfromvalue,comparevartovalue,gotoif,setvarfromvalue,' + .. 'getpartymonspecies,setvarfromvalue,comparevartovalue,gotoif,setvarfromvalue' + and party[2].args[1] == 0x8021 and party[3].args[1] == 0x8022 and party[5].target == 19 and party[7].args[1] == 0x8021 + and party[8].args[1] == 0x8020 and party[10].target == 25, + 'party menu (0x107) and Egg check (0x102): ' .. table.concat(pn, ',')) + -- after a trainer battle: 0x8D leaves 1 for a win (the bedroom battle's + -- "awesome Trainer" line runs on var == 1), 0x8E returns to the field + local res = Link2.rewriteCompound({ Link2.name(mk(0x8D, '1080')) }) + local G4b = require('src.script.Gen4Commands') + local Cmdb = require('src.script.Commands') + local function after(result) + local save = { vars = {} } + local ctx = { save = save, lastBattleResult = result } + Cmdb.g4_check_won_battle(ctx, res[1].args[1]) + return G4b.getVar(save, 0x8010) + end + check(#res == 1 and res[1].name == 'checkwonbattle' and after('win') == 1 and after('lose') == 0 + and Link2.ALIASES[0x8E][1] == 'returntofield' and Link2.ALIASES[0x8C][1] == 'blackoutfrombattle', + ('0x8D: 1 after a win, 0 after a loss (%s / %s)'):format(tostring(after('win')), tostring(after('lose')))) + -- the gift box: 0x153 the starter app, 0x10C the gift, 0x057 the name + local box = Link2.rewriteCompound({ Link2.name(mk(0x153, '2080')), + Link2.name(mk(0x10C, '1080218000000500')), Link2.name(mk(0x057, '01ef01')) }) + check(box[1].name == 'bwchoosestarter' and box[1].args[1] == 0x8020 + and box[2].name == 'givepokemon' and box[2].args[1] == 0x8021 and box[2].args[2] == 5 + and box[2].args[4] == 0x8010 and box[3].name == 'bufferspeciesnamefromvar' + and box[3].args[1] == 1 and box[3].args[2] == 495, + 'gift box: 0x153 starter app, 0x10C givepokemon, 0x057 species name') + local bagOps = Link2.rewriteCompound({ Link2.name(mk(0xB5, '008001801080')), Link2.name(mk(0xB7, '008001801080')), + Link2.name(mk(0xBB, '08801080')), Link2.name(mk(0x04E, '000880098000')), Link2.name(mk(0x052, '021080')) }) + check(bagOps[1].name == 'additem' and bagOps[1].args[3] == 0x8010 and bagOps[2].name == 'canfititem' + and bagOps[3].name == 'getitempocket' and bagOps[3].args[2] == 0x8010 + and bagOps[4].name == 'bufferitemname' and bagOps[4].args[1] == 0 and bagOps[4].args[2] == 0x8008 + and bagOps[5].name == 'bufferpocketname' and bagOps[5].args[1] == 2 and bagOps[5].args[2] == 0x8010, + 'the bag: 0xB5 add, 0xB7 fits, 0xBB pocket, 0x04E item name, 0x052 pocket name') + -- Juniper's nickname loop (0x105 / 0x110 / 0x054) and her Route 1 + -- catching demonstration (0x17D) + local lab = Link2.rewriteCompound({ Link2.name(mk(0x105, '108000000000')), Link2.name(mk(0x110, '228000007500')), + Link2.name(mk(0x054, '000000')), Link2.name(mk(0x053, '010000')), Link2.name(mk(0x17D, '')) }) + check(lab[1].name == 'openpokemonnamingscreen' and lab[1].args[1] == 0 and lab[1].args[2] == 0x8010 + and lab[2].name == 'bwmonhasnickname' and lab[2].args[1] == 0x8022 and lab[3].name == 'bufferpartymonnickname' + and lab[4].name == 'bufferpartymonspecies' and lab[4].args[1] == 1 and lab[5].name == 'bwcatchingtutorial', + 'the lab and Route 1: naming screen, nickname test, party name buffers, catching demo') + local Cm = require('src.script.Commands') + local Gx = require('src.script.Gen4Commands') + local nsave = { vars = {}, party = { { species = 495, nickname = 'Leafy', hp = 9, stats = { hp = 19 } } } } + local nctx = { save = nsave, game = { data = { pokemon = { [495] = { name = 'Snivy' } } } } } + Cm.g4_mon_has_nickname(nctx, 0x8022, 0) + local nick = Gx.getVar(nsave, 0x8022) + nsave.party[1].nickname = 'Snivy' + Cm.g4_mon_has_nickname(nctx, 0x8022, 0) + Cm.g4_mon_hp_full(nctx, 0x8010, 0) + local hurt = Gx.getVar(nsave, 0x8010) + nsave.party[1].hp = 19 + Cm.g4_mon_hp_full(nctx, 0x8010, 0) + check(nick == 1 and Gx.getVar(nsave, 0x8022) == 0 and hurt == 0 and Gx.getVar(nsave, 0x8010) == 1, + '0x110 answers a nickname, 0x101 full HP') + -- a new game's hide flags (the shared script of nothing but setflags) + local ng = b.constants and b.constants.gen4NewGame + local hasPlasma = false + for _, f in ipairs(ng and ng.flags or {}) do if f == 'FLAG_G4_0205' then hasPlasma = true end end + check(ng and #ng.flags == 131 and #ng.vars == 1 and ng.vars[1].id == 0x4116 and hasPlasma, + ('the new-game script: %s flags, %s var(s)'):format(tostring(ng and #ng.flags), tostring(ng and #ng.vars))) + -- triggers: the value is the second halfword (Accumula's speech at 0x407A == 2) + local acc = b.maps and b.maps.Z397 + local speech = acc and acc.coordEvents and acc.coordEvents[3] + check(speech and speech.var == 0x407A and speech.value == 2 and speech.x == 19 and speech.y == 17 and speech.width == 6, + "Accumula's speech trigger: var 0x407A == 2 at (19, 17), six wide") + local accScripts = ms.maps and ms.maps.Z397 + check(accScripts and accScripts.coords and accScripts.coords[3], "Accumula's triggers are linked") + local pc = ms.maps and ms.maps.Z398 + local nurse = pc and pc.shared and pc.shared.objects and pc.shared.objects[2] + check(nurse and nurse.entry == 0 and ms.bands[nurse.band] and ms.bands[nurse.band].member == 855, + 'the nurse (script 2100) links to the shared band, member 855') + local nurseOps = Link2.rewriteCompound({ Link2.name(mk(0x0D0, '23802480')), Link2.name(mk(0x0D4, '25802680')), + Link2.name(mk(0x0CE, '2180')), Link2.name(mk(0x100, '2080')), Link2.name(mk(0x0BE, '8e01070013000000')), + Link2.name(mk(0x03A, '0a0014000a000100')) }) + check(nurseOps[1].name == 'bwgetdate' and nurseOps[2].name == 'bwgetbirthday' and nurseOps[3].name == 'gettimeofday' + and nurseOps[4].name == 'checkpartypokerus' and nurseOps[5].name == 'warp' and nurseOps[5].args[1] == 398 + and nurseOps[5].args[3] == 7 and nurseOps[5].args[4] == 19 and nurseOps[6].name == 'message' and nurseOps[6].args[1] == 10, + 'the nurse (date, birthday, time, Pokerus), 0x0BE warp, 0x03A floating message') + -- stubs whose jump is the second load: 0x4D / 0x4F read 3 bytes (the + -- gym's TM gift decoded askew at 6), and the scripts end cleanly more often + local W = b.gen5_scripts and b.gen5_scripts.widths or {} + check(W[0x4D] == 3 and W[0x4F] == 3 and W[0x50] == 3 and W[0x61] == 3 and W[0x62] == 3, + ('stub widths: 4D %s, 4F %s, 61 %s, 62 %s'):format(tostring(W[0x4D]), tostring(W[0x4F]), tostring(W[0x61]), tostring(W[0x62]))) + -- GC chunks carry permissions in their second section (the Dreamyard) + local M5 = require('src.import.Gen5Maps') + local gc = M5.chunk(table.concat({ 'GC', string.char(4, 0), string.char(24, 0, 0, 0, 28, 0, 0, 0, 32, 0, 0, 0, 36, 0, 0, 0, 40, 0, 0, 0), + 'MDL.', 'PRM.', 'LYR.', 'BLD.' })) + check(gc and gc.permissions == 'PRM.' and gc.layer2 == 'LYR.' and gc.buildings == 'BLD.', 'a GC chunk: model, permissions, second layer, buildings') + local dy = b.maps and b.maps.Z152 + local walk = dy and dy.blocks and dy.blocks:byte((26 * dy.width + 12) * 2 + 2) + check(walk and walk < 0x80, "the Dreamyard's ground is walkable (permissions read from its GC chunks)") + -- the gym's curtains: invisible blockers seven wide + local gym = b.maps and b.maps.Z007 + local curtain + for _, o in ipairs(gym and gym.objects or {}) do if o.localId == 4 then curtain = o end end + check(curtain and curtain.gen5Blocker and curtain.buried and curtain.footprintW == 7 and curtain.footprintH == 1, + "Striaton gym's curtain: a 7 x 1 invisible blocker") + local Col = require('src.world.Collision') + local wall = { cellX = 9, cellY = 34, def = curtain } + check(Col.occupied({ wall }, 15, 34, {}) == wall and Col.occupied({ wall }, 16, 34, {}) == nil, 'the footprint blocks (9..15, 34) and no further') + -- the machines: TM01 Hone Claws, HM01 Cut, TM95 Snarl + local it = b.items or {} + check(it[328] and it[328].machine and it[328].machine.move == 468 and it[420].machine.move == 15 + and it[620] and it[620].machine and it[620].machine.move == 555, + 'machine moves: TM01 Hone Claws, HM01 Cut, TM95 Snarl') + local misc = Link2.rewriteCompound({ Link2.name(mk(0x116, '10800f00')), Link2.name(mk(0x0C2, '0900070000000a00')), + Link2.name(mk(0x056, '000a00')), Link2.name(mk(0x068, '20802180')) }) + check(misc[1].name == 'findpartyslotwithmove' and misc[1].args[2] == 15 and misc[2].name == 'warp' and misc[2].args[1] == 9 + and misc[3].name == 'buffertypename' and misc[3].args[2] == 10 and misc[4].name == 'getplayermappos', + '0x116 slot with move, 0x0C2 warp, 0x056 type name, 0x068 position') + -- trainers: what they say, and the object records that make them trainers + local tm = b.gen4_trainer_messages + check(tm and tm[9] and tm[9][0] == 135 and tm[9][1] == 136 and tm[9][2] == 137, + "trainer lines: Waiter Maxwell's challenge, defeat and after-battle are bank 189's 135..137") + local waiter + for _, o in ipairs(gym and gym.objects or {}) do if o.localId == 7 then waiter = o end end + check(waiter and waiter.trainer and waiter.trainer.id == 9 and waiter.sightRange == 6 and waiter.sightWays == 1, + "Striaton gym's waiter: trainer 9 by its script's band, sight 6 along its facing") + local tr = Link2.rewriteCompound({ Link2.name(mk(0x084, '2080')), Link2.name(mk(0x097, '20801080')), + Link2.name(mk(0x087, '208021801180')), Link2.name(mk(0x088, '218022802380')) }) + check(tr[1].name == 'gettrainerid' and tr[2].name == 'bwtrainerbeaten' and tr[3].name == 'printtrainerdialogue' + and tr[3].args[2] == 0x8021 and tr[4].name == 'gettrainermessagetypes', + 'the shared trainer script: trainer id, beaten, dialogue, message types') + local more = Link2.rewriteCompound({ Link2.name(mk(0x086, '38003e003f000000')), Link2.name(mk(0x025, '20001080')), + Link2.name(mk(0x095, '2080')), Link2.name(mk(0x006, '0100')) }) + check(more[1].name == 'multibattle' and more[1].args[1] == 56 and more[1].args[3] == 63 + and more[2].name == 'bwflagtovar' and more[2].args[1] == 0x20 and more[3].name == 'settrainerflag' + and more[4].name == 'noop', + "0x086 tag battle (Cheren 56 with 62 / 63), 0x025 flag into a var, 0x095 trainer flag, 0x006 no-op") + -- warps: 1-based destWarp (the engine reads warps[destWarp]) and extents + local house = b.maps and b.maps.Z401 + local mat = house and house.warps and house.warps[1] + local acc2 = b.maps and b.maps.Z397 + local back = mat and acc2 and acc2.warps[mat.destWarp] + check(mat and mat.width == 3 and back and back.x == 17 and back.y == 6, + "Accumula house's exit mat (3 wide) leads back to its own door at (17, 6)") + local extra = 0 + for _, w in ipairs(house and house.warps or {}) do if w.extentOf == 1 then extra = extra + 1 end end + check(extra == 2 and house.warps[2].destMap == 'Z402', "the mat's other two cells follow the real warps") + -- seams: Route 1's north edge meets Route 2 and Accumula, and the matrix + -- names Accumula as the owner of its chunk inside Route 2's box + local r1 = b.maps and b.maps.Z317 + local north = r1 and r1.connections and r1.connections.north + local hasAcc = false + for _, c in ipairs(north and north.list or {}) do if c.map == 'Z397' and c.offset == 32 then hasAcc = true end end + check(hasAcc, "Route 1's north seam reaches Accumula (offset 32)") + local mxs = b.gen4_map_matrices or {} + local acc3 = b.maps and b.maps.Z397 + local m0 = acc3 and mxs[acc3.layout] + local owner = m0 and m0.headers and m0.headers[math.floor(acc3.originY / 32) * m0.width + math.floor(acc3.originX / 32) + 1] + check(owner == 397, ('the matrix names zone 397 the owner of its chunk (%s)'):format(tostring(owner))) + local HP = require('src.world.Gen4HiddenPaths') + check(HP.applyToDef({}, { generation = 4, gen5 = true, layout = 0, blocks = '', behaviorCells = '' }, {}) == false, + "Platinum's hidden paths leave Black and White's maps alone") + local cas = b.maps and b.maps.Z028 + local ct = cas and cas.coordEvents and cas.coordEvents[1] + check(ct and ct.x == 4 and ct.y == 24 and ct.width == 1 and ct.height == 8, + "a trigger's second shape (+8 = 1: x, y, z, width, height): Castelia's 1 x 8 strip at (4, 24)") + -- the cameras: /a/0/6/0 records by the zone header's +0x1C bits 9..15 + local cam = b.maps and b.maps.Z006 and b.maps.Z006.gen5Camera + local pc = b.maps and b.maps.Z398 and b.maps.Z398.gen5Camera + check(cam and cam.id == 'bw_0' and cam.distance == 237 and math.abs(cam.pitch - 53.22) < 0.01 + and math.abs(cam.halfFov - 20.0) < 0.01 and pc and pc.id == 'bw_3' and pc.distance == 229, + ('cameras: Striaton the default (237 at 53.2 degrees), a Pokemon Center record 3 (%s)'):format(tostring(pc and pc.id))) + -- the rails: zone -> ARM9 index table -> /a/1/0/9 -> /a/0/7/9 + local rails = b.gen5_rails or {} + local cz = b.maps and b.maps.Z028 and b.maps.Z028.gen5Rail + local bz = b.maps and b.maps.Z249 and b.maps.Z249.gen5Rail + local bridge = rails[11] + check(cz and cz.file == 5 and bz and bz.file == 11 and bridge and bridge.points[0] + and bridge.points[0].name == 'RE_POINT_0' and bridge.lines[0].name:match('^RE_LINE_0') + and math.abs(bridge.points[0].x - 1552) < 0.01 and rails[5] and rails[5].curves[0].kind == 1, + "rails: Castelia's centre is file 5 (a ring about a curve centre), Skyarrow Bridge file 11 (RE_POINT_0 at x 1552)") + -- the cutscene camera: 0x143's look-at point becomes Platinum's free camera + local cam = Link2.rewriteCompound({ Link2.name(mk(0x143, 'de2500002bd00e000080840200000000008047021800')) }) + check(#cam == 1 and cam[1].name == 'addfreecamera' and cam[1].args[1] == 644 and cam[1].args[2] == 583 + and Link2.name(mk(0x147, '1e00')).name == 'restorecamera', + ('0x143 looks at absolute tile (%s, %s); 0x147 hands the camera back'):format(tostring(cam[1] and cam[1].args[1]), tostring(cam[1] and cam[1].args[2]))) + -- tile heights: the gym's dais is 14 world units, its floor 0 + local gym = b.maps and b.maps.Z007 + local he = gym and gym.gen4HeightEdits or {} + check(he['12,3'] == 14 and he['12,9'] == nil and he['9,1'] == 14, + ('Z007 heights: dais %s, floor %s'):format(tostring(he['12,3']), tostring(he['12,9']))) + local r1 = b.encounters and b.encounters[77] + check(r1 and r1.grass[1].species == 504 and r1.grass[2].species == 506 and r1.grass[1].minLevel == 2, + 'Route 1 grass: Patrat (504) and Lillipup (506) from level 2') + local tc = b.type_chart + local fightNormal + for _, m in ipairs(tc and tc.matchups or {}) do + if m.attacker == 'FIGHTING' and m.defender == 'NORMAL' then fightNormal = m.multiplier end + end + check(fightNormal == 20, 'type chart (17 x 17): Fighting is x2 against Normal') + local font = b.font + check(font and font.pages and font.pages.message and #font.charmap > 4000, + 'the NFTR message font and its charmap (Gen5Font)') + do + local Narc, F = require('src.import.NarcArchive'), require('src.import.Gen5Font') + local rom = require('src.import.NdsRom').open(black) + local f = rom and F.parse(Narc.parse(rom:read('/a/0/2/3')):get(0)) + local blankTop = true + if f then + local g = F.glyph(f, f.codes[32]) + for _, role in ipairs(g) do if role ~= 0 then blankTop = false end end + end + check(f and f.recordBytes == 3 and f.widths[f.codes[84]] == 6 and f.widths[f.codes[32]] == 4 and blankTop, + "each glyph slot opens with its width record: 'T' advances 6, the space 4 and draws nothing") + end + local rep = b.gen5_version or {} + check(rep.family == 'bw' and #(rep.notPorted or {}) > 0, 'what is not ported yet is reported') +end + +local w = extract(white, 'white') +check(w ~= nil and w.pokemon and w.pokemon[644] and w.pokemon[644].name == 'Zekrom' + and w.pokemon[643] and w.pokemon[643].name == 'Reshiram', 'White: the stages run (Reshiram, Zekrom)') + +-- third person: the eye stops in front of a wall behind the player, not over water +do + local Ground = require('src.render.Gen4Ground') + local function fake(word) + return { offsetX = 0, offsetY = 0, + permissionWordAt = function(_, tx, ty) if tx == 10 and ty == 13 then return word end return 0 end, + groundY = function() return 0 end } + end + local function view() return { pivotX = 168, pivotY = 16, pivotZ = 168, x = 168, y = 40, z = 264 } end + local wall, v1 = fake(0x8001), view() + Ground.clearCameraLine(wall, v1) + local lake, v2 = fake(0x8000 + 0x10), view() + Ground.clearCameraLine(lake, v2) + local surf = require('src.import.Gen4Behaviors').isSurfable(0x10) + check(v1.z < 208 and v1.z > 168 and math.abs((v1.y - 16) / (v1.z - 168) - 24 / 96) < 1e-6 + and (not surf or v2.z == 264), + ('third person pulls in to z %.1f in front of a wall two tiles back (along its own line), not over water (%s)'):format(v1.z, tostring(v2.z))) +end + +print(('%d checks, %d failed'):format(PASS + FAIL, FAIL)) +if FAIL > 0 then os.exit(1) end diff --git a/tools/map-editor/panels/Warps.lua b/tools/map-editor/panels/Warps.lua index 23e7303b4..25d379c5a 100644 --- a/tools/map-editor/panels/Warps.lua +++ b/tools/map-editor/panels/Warps.lua @@ -202,9 +202,13 @@ local function problemWith(S, d, warp, i) end end if isGen2(S) then - local fires = cellEntrance(S, d, warp.x, warp.y) + -- the class comes back already formatted ("$12", or a tile for an adopted + -- tileset); `cls` was never in scope here, so every inert warp raised -- + -- an imported Gen 1 map, whose warps sit on Gen 1 door cells, took the + -- whole warps list down on the frame it arrived + local fires, what = cellEntrance(S, d, warp.x, warp.y) if fires == false then - return string.format("inert - cell is $%02X, not a door", cls) + return "inert - cell is " .. tostring(what or "?") .. ", not a door" end end return nil diff --git a/tools/rom_manifest_black.json b/tools/rom_manifest_black.json new file mode 100644 index 000000000..a8ff01c1d --- /dev/null +++ b/tools/rom_manifest_black.json @@ -0,0 +1,13 @@ +{ + "format": 4, + "generation": 5, + "romSha1": "26ad0b9967aa279c4a266ee69f52b9b2332399a5", + "gameCode": "IRBO", + "title": "POKEMON B", + "notes": [ + "A Nintendo DS cartridge is a filesystem: every extraction stage opens a file by", + "its path (or, for most of this game, by its numbered /a/x/y/z path), so this", + "manifest carries only which dump is this game -- RomImporter reads romSha1.", + "Decomp: squiddonaut/pokeblack (pokeblack.us.nds)." + ] +} \ No newline at end of file diff --git a/tools/rom_manifest_heartgold.json b/tools/rom_manifest_heartgold.json new file mode 100644 index 000000000..9419bfa61 --- /dev/null +++ b/tools/rom_manifest_heartgold.json @@ -0,0 +1,13 @@ +{ + "format": 4, + "generation": 4, + "romSha1": "4fcded0e2713dc03929845de631d0932ea2b5a37", + "gameCode": "IPKE", + "title": "POKEMON HG", + "notes": [ + "A Nintendo DS cartridge is a filesystem: every extraction stage opens a file by", + "its path (or, for most of this game, by its numbered /a/x/y/z path), so this", + "manifest carries only which dump is this game -- RomImporter reads romSha1.", + "Decomp: pret/pokeheartgold (pokeheartgold.us.nds)." + ] +} \ No newline at end of file diff --git a/tools/rom_manifest_soulsilver.json b/tools/rom_manifest_soulsilver.json new file mode 100644 index 000000000..fc757607f --- /dev/null +++ b/tools/rom_manifest_soulsilver.json @@ -0,0 +1,13 @@ +{ + "format": 4, + "generation": 4, + "romSha1": "f8dc38ea20c17541a43b58c5e6d18c1732c7e582", + "gameCode": "IPGE", + "title": "POKEMON SS", + "notes": [ + "A Nintendo DS cartridge is a filesystem: every extraction stage opens a file by", + "its path (or, for most of this game, by its numbered /a/x/y/z path), so this", + "manifest carries only which dump is this game -- RomImporter reads romSha1.", + "Decomp: pret/pokeheartgold (pokesoulsilver.us.nds)." + ] +} \ No newline at end of file diff --git a/tools/rom_manifest_white.json b/tools/rom_manifest_white.json new file mode 100644 index 000000000..c03f66cab --- /dev/null +++ b/tools/rom_manifest_white.json @@ -0,0 +1,13 @@ +{ + "format": 4, + "generation": 5, + "romSha1": "bc696a0dfb448c7b3a8a206f0f8214411a039208", + "gameCode": "IRAO", + "title": "POKEMON W", + "notes": [ + "A Nintendo DS cartridge is a filesystem: every extraction stage opens a file by", + "its path (or, for most of this game, by its numbered /a/x/y/z path), so this", + "manifest carries only which dump is this game -- RomImporter reads romSha1.", + "Decomp: squiddonaut/pokeblack (the same engine; White is not built by it)." + ] +} \ No newline at end of file diff --git a/tools/trailer/SOCIAL.md b/tools/trailer/SOCIAL.md new file mode 100644 index 000000000..3d8b287e4 --- /dev/null +++ b/tools/trailer/SOCIAL.md @@ -0,0 +1,228 @@ +# RetroRecomped — social copy + +Fill in `[DOWNLOAD LINK]` before posting. Every post ends with the same two links: +- Tilt Rips (pack opening): https://tiltrips.com/r/GEN2RECOMP/ +- Discord: https://discord.gg/4VXnEnePT + +> **Disclaimer** (add it to every YouTube description; include it in X/TikTok bios or pinned replies where space allows): +> RetroRecomped is a non-commercial fan project. It is not affiliated with or endorsed by Nintendo, Game Freak, Creatures or The Pokémon Company. No game files are included; you import your own legally obtained ROMs. + +--- + +## VIDEO 1 — Trailer + +### YouTube + +**Title** (pick one): +- RetroRecomped — Official Trailer | Pokémon Gen 1–4 Rebuilt in 3D Voxels +- RetroRecomped Trailer: Walk Johto, Kanto, Hoenn & Sinnoh in First Person +- Your Favorite Pokémon Games, Rebuilt — RetroRecomped Official Trailer + +**Description:** +``` +You remember it like this. Now walk back in. + +RetroRecomped is a recompiled engine for the classic Pokémon games. Import your own ROM and play it natively, then make it new: 3D voxel dioramas, first and third person, battles fought on the map itself, a real day/night cycle, and a full mod system. + +▶ Download: [DOWNLOAD LINK] +▶ Discord: https://discord.gg/4VXnEnePT +▶ Rip packs with us on Tilt Rips: https://tiltrips.com/r/GEN2RECOMP/ + +SUPPORTED GAMES +• Gen I: Red, Blue, Yellow +• Gen II: Gold, Silver, Crystal (plus Prism & Polished Crystal) +• Gen III: FireRed, Emerald +• Gen IV: Platinum +• COMING SOON: HeartGold, SoulSilver, Black & White + +FEATURES +• Voxel diorama mode: every town and route rebuilt in 3D +• First person and third person camera modes +• Battles in the world, staged on the map where the encounter happens +• Real day, dusk and night lighting +• Built to be modded: map packs, sprite sets, voxel mods and your own mods +• Map editor (see our map editor video) + +PLATFORMS +Windows · macOS · Linux · Steam Deck · Android · Switch · iOS + +All footage is recorded in-engine. Nothing is pre-rendered. + +CHAPTERS +0:00 RetroRecomped +0:04 Flat to voxel +0:12 Day & night +0:18 Johto +0:24 First person +0:30 Third person +0:36 Battles in the world +0:48 Kanto +0:55 Hoenn +1:02 Sinnoh +1:10 Coming soon +1:16 Built to be modded +1:21 Links + +RetroRecomped is a non-commercial fan project. It is not affiliated with or endorsed by Nintendo, Game Freak, Creatures or The Pokémon Company. No game files are included; you import your own legally obtained ROMs. + +#RetroRecomped #Pokemon #PokemonCrystal #Voxel #Recompiled #IndieDev #RetroGaming +``` + +**Tags** (comma-separated, under YouTube's 500-character limit): +``` +RetroRecomped, Gen2Recomped, pokemon recomp, pokemon voxel, pokemon 3d, pokemon crystal 3d, pokemon first person, pokemon third person, pokemon gold silver, pokemon firered, pokemon emerald, pokemon platinum, heartgold soulsilver, pokemon black white, recompiled, decomp, retro gaming, voxel, modding, pokemon mods, steam deck, fan project, trailer +``` + +**Thumbnail text:** "WALK BACK IN" in large type over the flat-to-voxel New Bark frame, with the RetroRecomped logo. + +--- + +### X / Twitter + +**Main post** (attach the trailer): +``` +You remember it like this. Now walk back in. + +RetroRecomped: Red to Platinum, rebuilt in 3D voxels, with first person, third person and battles on the map itself. + +HeartGold, SoulSilver, Black & White coming soon. + +[DOWNLOAD LINK] +``` +**Reply 1:** +``` +Built to be modded: voxel mods, map packs, sprite sets, your own mods, and a full map editor. + +Windows · macOS · Linux · Steam Deck · Android · Switch · iOS + +Discord: https://discord.gg/4VXnEnePT +``` +**Reply 2:** +``` +Rip packs with us on Tilt Rips 👉 https://tiltrips.com/r/GEN2RECOMP/ +``` +**Hashtags** (use 2–3): `#RetroRecomped #Pokemon #indiedev #voxel` + +--- + +### Bluesky / Threads +``` +You remember it like this. Now walk back in. 🎮 + +RetroRecomped rebuilds Red through Platinum in 3D voxels: first and third person, battles on the map, real day and night, and full mod support. + +HeartGold, SoulSilver, Black & White coming soon. + +▶ [DOWNLOAD LINK] +💬 https://discord.gg/4VXnEnePT +``` + +### TikTok / YouTube Shorts / Instagram Reels +Cut to 9:16: the flat-to-voxel reveal, then first person, then the battle, then the logo. + +**Caption:** +``` +POV: you walk back into New Bark Town in 3D 🌳 RetroRecomped: Red to Platinum, rebuilt. HeartGold/SoulSilver/BW coming soon. Link in bio. +#pokemon #pokemoncrystal #retrogaming #voxel #nostalgia #gaming #RetroRecomped +``` + +### Reddit +Check each subreddit's rules on fan projects and ROMs before posting. + +**Title:** +``` +I've been rebuilding the classic Pokémon games in a recompiled engine. Here's the RetroRecomped trailer (voxels, first person, Gen 1–4) +``` +**Body:** the YouTube description's FEATURES and SUPPORTED GAMES lists, plus one line of your own about the project, plus the disclaimer. + +### Discord announcement +``` +@everyone 🎬 **The RetroRecomped trailer is out!** +Red → Platinum in 3D voxels, first and third person, battles on the map, and HeartGold/SoulSilver/Black/White coming soon. +▶ [YOUTUBE LINK] +Like it, share it, and drop your favorite moment below 👇 +``` + +--- + +## VIDEO 2 — Map editor showcase + +Adjust the bullet list once the recording is final, so it only names features shown on screen. + +### YouTube + +**Title** (pick one): +- RetroRecomped Map Editor: Edit Pokémon Crystal Maps & Place 3D Models in Platinum +- Build Your Own Pokémon Maps: RetroRecomped Map Editor Showcase +- Editing Johto Live + Placing Gen 4 3D Models | RetroRecomped Map Editor + +**Description:** +``` +A tour of the RetroRecomped map editor: editing Pokémon Crystal maps live, then placing, swapping and moving real 3D models on a Platinum (Gen 4) map. + +▶ Download: [DOWNLOAD LINK] +▶ Discord (share your maps!): https://discord.gg/4VXnEnePT +▶ Rip packs with us on Tilt Rips: https://tiltrips.com/r/GEN2RECOMP/ + +IN THIS VIDEO +• Crystal: painting tiles and editing collision, then walking the result in voxel 3D +• Platinum: placing Gen 4 3D models, swapping them for others, moving and rotating them +• Saving your edits as a map pack you can share as a mod + +CHAPTERS +0:00 Intro +[fill in after the edit] + +Watch the full trailer: [TRAILER LINK] + +RetroRecomped is a non-commercial fan project. It is not affiliated with or endorsed by Nintendo, Game Freak, Creatures or The Pokémon Company. No game files are included; you import your own legally obtained ROMs. + +#RetroRecomped #Pokemon #MapEditor #RomHacking #Modding #PokemonCrystal #PokemonPlatinum +``` + +**Tags:** +``` +RetroRecomped, pokemon map editor, pokemon crystal map editor, pokemon platinum map editor, gen 4 models, pokemon 3d models, rom hacking, pokemon rom hack, pokemon modding, map maker, level editor, pokemon voxel, decomp, fan game tools, retro gaming +``` + +**Thumbnail text:** "BUILD YOUR OWN JOHTO" over a split image: a Crystal map with the editor open on one side, a Platinum 3D model on the other. + +### X / Twitter +``` +The RetroRecomped map editor 🛠️ + +Paint Crystal's maps live, then drop real Gen 4 3D models into Platinum, swap them out and move them around. + +Share your maps as mods 👉 https://discord.gg/4VXnEnePT +[YOUTUBE LINK] +``` +**Reply:** `Rip packs with us on Tilt Rips: https://tiltrips.com/r/GEN2RECOMP/` + +### Bluesky / Threads +``` +Map editor showcase 🛠️ Edit Pokémon Crystal maps live, then place and swap real 3D models in Platinum, all inside RetroRecomped. +▶ [YOUTUBE LINK] +💬 https://discord.gg/4VXnEnePT +``` + +### TikTok / Shorts / Reels +**Caption:** +``` +I put a Pokémon Center where it doesn't belong 😅 RetroRecomped map editor: Crystal + Platinum 3D models. Link in bio. +#pokemon #romhacking #mapeditor #gamedev #RetroRecomped +``` + +### Discord announcement +``` +🛠️ **Map editor showcase is live!** Crystal map editing plus Gen 4 3D model placement in Platinum. +▶ [YOUTUBE LINK] +Post your creations in #maps 👀 +``` + +--- + +## Posting tips +- **YouTube:** upload `trailer_share.mp4` (1080p30, standard color range). +- **X:** posts are capped at 2:20 on standard accounts, so the 1:30 trailer fits. +- **Shorts, TikTok and Reels:** need a separate 9:16 cut. I can render one from the same Remotion project. +- **Pinned comment:** pin a comment on YouTube with the download link and the Discord link. diff --git a/tools/trailer/TRAILER.md b/tools/trailer/TRAILER.md new file mode 100644 index 000000000..a4c27d154 --- /dev/null +++ b/tools/trailer/TRAILER.md @@ -0,0 +1,81 @@ +# Gen2Recomped — Trailer (shot-matched script) + +Every shot below is **real footage recorded by the harness**. Nothing is mocked up. +`tools/trailer/capture_all.ps1` writes each shot as `/_.mp4` +(30 fps 1920×1080 H.264). Frames stream straight into ffmpeg, so no PNGs are written. +Every Gen 2 and Gen 3 shot is recorded with the DramaticShapes voxel diorama on. +Platinum is natively 3D. It is recorded on the cartridge camera (CAM TILT rung 1) with the bottom screen off; its `third_person` shot uses rung 9. +Default output: `G:\Gen2Recomped\trailer_footage`. + +Capture identity: `%APPDATA%\LOVE\trailer_cap\options.lua`, a copy of your options with these changes: +- dataDir `G:\Gen2Recomped` +- DramaticShapes on, with DAYTIME = SYNC so the harness's pinned hour drives day, dusk and night +- BATTLE_ART_VOXEL_GEN2 off + +Your own options are untouched. + +Target length is **~90 s** at 30 fps. Music: one rising track that hits a beat at 0:20 (the voxel reveal) and at 1:05 (the "coming soon" card). + +| Time | Shot | On screen | Text / VO | +|---|---|---|---| +| 0:00–0:04 | — | Black. A Game Boy start-up chime. Logo fades in. | **GEN2RECOMPED** | +| 0:04–0:12 | `crystal_voxel_reveal` (first half) | New Bark Town, flat, exactly as it shipped in 2000. | *"You remember it like this."* | +| 0:12–0:20 | `crystal_voxel_reveal` (second half) | Mid-walk the town lifts into 3D voxels and the camera pulls back. | *"Now walk back in."* | +| 0:20–0:26 | `crystal_newbark_day` → `crystal_newbark_night` | Same town, day cross-fading to night. | REAL DAY / NIGHT | +| 0:26–0:32 | `crystal_ecruteak_dusk`, `crystal_goldenrod` | Two quick cuts through Johto cities. | | +| 0:32–0:38 | `crystal_first_person` | First person, walking down the river. | **FIRST PERSON** | +| 0:38–0:44 | `crystal_third_person` | Over-the-shoulder run through Cherrygrove. | **THIRD PERSON** | +| 0:44–0:52 | `crystal_battle_shiny` | Shiny Gyarados, fought on the map itself. | **BATTLES IN THE WORLD** | +| 0:52–0:56 | `crystal_overview` | Full diorama view of Violet City. | | +| 0:56–1:02 | `firered_voxel_reveal`, `firered_pallet`, `firered_battle` | Kanto in voxels. | GEN I · GEN II · GEN III | +| 1:02–1:08 | `emerald_slateport`, `emerald_lilycove`, `emerald_first_person` | Hoenn. | | +| 1:08–1:16 | `platinum_jubilife`, `platinum_third_person`, `platinum_snowpoint`, `platinum_hearthome`, `platinum_battle` | Sinnoh, native 3D, single screen. | **GEN IV** | +| 1:16–1:22 | title cards | HeartGold / SoulSilver / Black / White logos, silhouetted. | **COMING SOON** | +| 1:22–1:27 | launcher capture (see below) | Mod list and mod toggles. | **BUILT TO BE MODDED** | +| 1:27–1:30 | end card | Logo, platforms, links. | Discord · Tilt Rips referral | + +End-card text: +- Red · Blue · Yellow · Gold · Silver · Crystal · FireRed · Emerald · Platinum +- Windows · macOS · Linux · Steam Deck · Android · Switch · iOS +- "Rip packs with us on Tilt Rips — tiltrips.com/r/GEN2RECOMP" (Tilt Rips is a pack-opening site) +- discord.gg/4VXnEnePT + +Not captured by the harness yet: +- **Gen I (Red/Blue/Yellow):** import a Red ROM first, then add a `red` block to `capture.lua`. +- **Launcher / mod menu:** record with OBS (Win+Alt+R also works) for 5 s. +- **HGSS / BW title cards:** made in the editor. + +## Re-recording + +```powershell +powershell -ExecutionPolicy Bypass -File tools/trailer/capture_all.ps1 -Versions crystal -Only voxel_reveal,first_person +``` + +Environment knobs are listed in the header of `tools/trailer/capture.lua`. ffmpeg lives in +`G:\Tools\ffmpeg-9.0.2-essentials_build\bin` (`-Ffmpeg` overrides the path). + +## Editing: Remotion + +`tools/trailer/remotion` is the cut as code. `src/Trailer.tsx` follows the beat sheet above, and +`public/footage` is a junction to `G:\Gen2Recomped\trailer_footage`. + +```powershell +cd tools/trailer/remotion +npx remotion studio src/index.ts # preview and scrub in the browser +npx remotion render src/index.ts Trailer out/trailer.mp4 --crf 16 +``` + +Remotion writes full-range color (`yuvj420p`). Make the copy for Clipchamp and uploads with: + +```powershell +G:\Tools\ffmpeg-9.0.2-essentials_build\bin\ffmpeg.exe -i out/trailer.mp4 -vf "scale=in_range=full:out_range=tv,format=yuv420p" -c:v libx264 -preset slow -crf 18 -movflags +faststart out/trailer_share.mp4 +``` + +To add music, put `public/music.mp3` in place and set `MUSIC = true` in `Trailer.tsx`. +Remotion is free for individuals and companies of up to 3 people; larger teams need a company license. + +## Finishing in Clipchamp + +Clipchamp has no scripting API, so the handoff is files: +- Import `out/trailer.mp4` for music, voice-over and Clipchamp's own effects. +- Or import the per-shot MP4s from `G:\Gen2Recomped\trailer_footage` and cut by hand, using the table above as the edit list. diff --git a/tools/trailer/capture.lua b/tools/trailer/capture.lua new file mode 100644 index 000000000..441cefd77 --- /dev/null +++ b/tools/trailer/capture.lua @@ -0,0 +1,602 @@ +-- tools/trailer/capture.lua -- TRAILER FOOTAGE, recorded from the real game. +-- +-- A POKEPORT_DRIVER script: it plays scripted shots (walks through towns, day +-- and night, battles, the voxel diorama, first and third person, Platinum on +-- the cartridge camera, single screen) and streams every other rendered frame +-- into ffmpeg, so each shot comes out as one 30 fps MP4: +-- +-- /_.mp4 +-- +-- One run records the shots for ONE game (POKEPORT_VERSION). Every Gen 2 and +-- Gen 3 shot is recorded with DramaticShapes' voxel diorama ON, except the +-- "voxel_reveal" shot that starts flat and switches the voxels on mid-walk. +-- tools/trailer/capture_all.ps1 runs every game. +-- +-- CAMERA CRAFT: strolls keep to open ground (no tree line between the lens +-- and the player), voxel cameras settle before the first frame, and first / +-- third person steer the free camera's look along a planned path, easing +-- through each corner, so the view turns the way the player walks. +-- +-- SMOOTH FOOTAGE ON A SLOW RUN: the logic steps a fixed 1/60 per frame, the +-- wall clock (love.timer.getTime, which water and shaders animate from) is +-- replaced by a frame clock, and encoding happens off the main thread in +-- ffmpeg. The window may crawl while recording; the video does not. +-- +-- Env: OUT (required), FFMPEG (path, default "ffmpeg"), CRF (default 14), +-- FORMAT (mp4 | png), VOXEL (level, default 4), WIDTH/HEIGHT (default +-- 1920x1080), ONLY (comma list of shot names), FPS_STEP (default 2 = 30 fps), +-- WRITERS (PNG threads when FORMAT=png, default 4). + +return function(game) + local U = dofile("tests/drivers/util.lua") + local OUT = assert(os.getenv("OUT"), "set OUT to the footage folder") + local VOX = tonumber(os.getenv("VOXEL") or "4") or 4 + local STEP = tonumber(os.getenv("FPS_STEP") or "2") or 2 + local ONLY = {} + for name in (os.getenv("ONLY") or ""):gmatch("[^,]+") do ONLY[name] = true end + local GameVersion = require("src.core.GameVersion") + local version = GameVersion.get() + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + -- a 1080p window, so the frames are trailer-sized + local W, H = tonumber(os.getenv("WIDTH") or "1920"), tonumber(os.getenv("HEIGHT") or "1080") + pcall(love.window.setMode, W, H, { resizable = true, vsync = 0 }) + U.wait(10) + + -- THE CLOCK: Gen 2's day and night come from the wall clock, so a shot pins + -- the hour by answering os.date("%H") itself + local realDate = os.date + local pinnedHour = nil + os.date = function(fmt, t) + if pinnedHour and fmt == "%H" then return ("%02d"):format(pinnedHour) end + if pinnedHour and fmt == "*t" then + local r = realDate("*t", t); r.hour = pinnedHour; return r + end + return realDate(fmt, t) + end + + -- THE FRAME CLOCK: one 1/60 tick per logic step, whatever the wall says + local realGetTime = love.timer.getTime + local clock = realGetTime() + local realUpdate = love.update + love.update = function(dt) clock = clock + 1 / 60; return realUpdate(dt) end + love.timer.getTime = function() return clock end + + -- THE WRITER: captureScreenshot hands the frame to a channel and ONE worker + -- thread streams it, raw RGBA, into an ffmpeg process per shot -- an MP4 + -- comes out, no PNGs touch the disk, and ffmpeg's own threads do the + -- encoding. FORMAT=png keeps the old image-sequence output instead. + local FORMAT = os.getenv("FORMAT") or "mp4" + local FFMPEG = os.getenv("FFMPEG") or "ffmpeg" + local CRF = os.getenv("CRF") or "14" + local WRITER = [[ + local ch, format, ffmpeg, crf, fps = ... + require("love.image") + local pipe, current + local function close() + if pipe then pipe:close(); pipe = nil end + current = nil + end + while true do + local job = ch:demand() + if job == "stop" then close(); return end + if format == "png" then + local fd = job.img:encode("png") + local f = io.open(("%s/%05d.png"):format(job.dir, job.n), "wb") + if f then f:write(fd:getString()); f:close() end + else + if job.dir ~= current then + close() + local w, h = job.img:getDimensions() + local cmd = ('"%s" -y -loglevel error -f rawvideo -pix_fmt rgba -s %dx%d -r %d -i - ' + .. '-c:v libx264 -preset slow -crf %s -pix_fmt yuv420p -movflags +faststart "%s.mp4"') + :format(ffmpeg, w, h, fps, crf, job.dir) + if package.config:sub(1, 1) == "\\" then cmd = '"' .. cmd .. '"' end + pipe = io.popen(cmd, "wb") + current = job.dir + end + if pipe then pipe:write(job.img:getString()) end + end + job.img:release() + end + ]] + local queue = love.thread.getChannel("trailer_frames") + local writers = {} + for i = 1, (FORMAT == "png") and (tonumber(os.getenv("WRITERS") or "4") or 4) or 1 do + writers[i] = love.thread.newThread(WRITER) + writers[i]:start(queue, FORMAT, FFMPEG, CRF, math.floor(60 / STEP)) + end + local function capture(dir, n) + -- back-pressure: 1080p frames are 8 MB each in the queue + while queue:getCount() > 24 do realGetTime(); love.timer.sleep(0.004) end + love.graphics.captureScreenshot(function(img) queue:push({ dir = dir, n = n, img = img }) end) + end + + -- the fit zoom, not whatever survey zoom the player's options hold + local Zoom = require("src.render.Zoom") + + -- the save: a fresh one with the player's own options kept + local kept = game.save and game.save.options + U.freshSave(game) + if kept then game.save.options = kept end + game.save.options.zoom = 0 + Zoom.offset = 0 + game.save.player.name = (version == "platinum") and "LUCAS" or "GOLD" + + local function speciesByName(name) + for id, def in pairs(game.data.pokemon or {}) do + if type(def) == "table" and type(def.name) == "string" and def.name:upper() == name then return id end + end + return nil + end + local function mon(name, level) + local id = speciesByName(name) + return id and Pokemon.new(game.data, id, level) or nil + end + local party = {} + for _, pick in ipairs({ { "TYPHLOSION", 40 }, { "CHARIZARD", 42 }, { "INFERNAPE", 40 }, { "PIKACHU", 30 } }) do + local m = mon(pick[1], pick[2]) + if m then party[#party + 1] = m end + if #party >= 2 then break end + end + game.save.party = party + + -- where a shot goes: /_.mp4, or a folder of PNGs + local function newShotDir(shot) + local dir = OUT .. "/" .. version .. "_" .. shot + if FORMAT == "png" then + if love._os == "Windows" then os.execute('mkdir "' .. dir:gsub("/", "\\") .. '" 2>nul') + else os.execute('mkdir -p "' .. dir .. '"') end + end + return dir + end + + -- one frame of footage every STEP frames + local function record(shot, frames) + local dir = newShotDir(shot) + local n = 0 + for f = 1, frames do + if f % STEP == 0 then + n = n + 1 + capture(dir, n) + end + coroutine.yield() + end + U.log(("recorded %s: %d frames -> %s"):format(shot, n, dir)) + end + + -- OPEN GROUND: the diorama camera looks north from the south, so whatever + -- stands in the rows just below the player (a tree line, a roof) stands + -- between it and the lens. A cell is open when that strip is walkable. + local function openScore(cx, cy) + local map = game.overworld and game.overworld.map + if not map then return 9 end + local n = 0 + for dy = 1, 3 do + for dx = -1, 1 do + local x, y = cx + dx, cy + dy + if map:inBounds(x, y) and map:isWalkableCell(x, y) then n = n + 1 end + end + end + return n + end + local strict = 7 -- openScore a walked cell needs; 0 = anywhere + -- the nearest cell the player can stand on, from the map's middle outwards + local function standable(mapId, x, y) + U.teleport(game, mapId, x, y, "down") + local ow = game.overworld + local map = ow and ow.map + if not (map and map.isWalkableCell) then return x, y end + if map:isWalkableCell(x, y) then return x, y end + for r = 1, 24 do + for dy = -r, r do + for dx = -r, r do + if math.abs(dx) == r or math.abs(dy) == r then + local cx, cy = x + dx, y + dy + if map:inBounds(cx, cy) and map:isWalkableCell(cx, cy) then return cx, cy end + end + end + end + end + return x, y + end + local function goTo(mapId, x, y, facing) + if not game.data.maps[mapId] then U.log("no map " .. tostring(mapId)); return false end + local m = game.data.maps[mapId] + x = x or math.floor((m.width or 20) / 2) + y = y or math.floor((m.height or 18) / 2) + local sx, sy = standable(mapId, x, y) + -- then the nearest cell with open ground in front of the camera + local map = game.overworld and game.overworld.map + if map then + local best, bestD + for dy = -14, 14 do + for dx = -14, 14 do + local cx, cy = sx + dx, sy + dy + if map:inBounds(cx, cy) and map:isWalkableCell(cx, cy) and openScore(cx, cy) >= 9 then + local d = math.abs(dx) + math.abs(dy) + if not bestD or d < bestD then best, bestD = { cx, cy }, d end + end + end + end + if best then sx, sy = best[1], best[2] end + end + U.teleport(game, mapId, sx, sy, facing or "down") + U.wait(60) + return true + end + -- ------------------------------------------------- walking that knows the map + -- + -- A breadth-first search over the cells the player can stand on (and that + -- nobody stands on), so a stroll goes AROUND houses and trees instead of + -- into them. The tour picks a far cell, then a second leg in another + -- direction, and walks it one tile at a time, re-planning when something + -- (a wandering NPC) blocks the way. + local Pipelines = require("src.render.Pipelines") + local function voxel(level) pcall(Pipelines.setLevel, "voxel", level) end + local function tiltshift(level) pcall(Pipelines.setLevel, "tiltshift", level) end + local DIRS = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } + local Collision = require("src.world.Collision") + local function free(cx, cy) + local ow = game.overworld + local map = ow and ow.map + if not (map and map:inBounds(cx, cy) and map:isWalkableCell(cx, cy)) then return false end + local okO, occ = pcall(Collision.occupied, ow.entities or ow.npcs or {}, cx, cy, ow.player) + return not (okO and occ) + end + -- strict: 0 anywhere, a number = that much open ground in front, "wide" = + -- all four neighbours walkable too (a free camera's eye clears the walls) + local function cellOK(cx, cy) + if strict == 0 then return true end + if strict == "wide" then + local map = game.overworld.map + for _, v in pairs(DIRS) do + if not (map:inBounds(cx + v[1], cy + v[2]) and map:isWalkableCell(cx + v[1], cy + v[2])) then return false end + end + return true + end + return openScore(cx, cy) >= strict + end + local function bfs(sx, sy) + local dist, prev, queue, head = { [sx .. "," .. sy] = 0 }, {}, { { sx, sy } }, 1 + while head <= #queue do + local c = queue[head]; head = head + 1 + local d = dist[c[1] .. "," .. c[2]] + if d < 60 then + for name, v in pairs(DIRS) do + local nx, ny = c[1] + v[1], c[2] + v[2] + local k = nx .. "," .. ny + if dist[k] == nil and free(nx, ny) and cellOK(nx, ny) then + dist[k], prev[k] = d + 1, { c[1], c[2], name } + queue[#queue + 1] = { nx, ny } + end + end + end + end + return dist, prev + end + local function pathTo(prev, sx, sy, tx, ty) + local steps, k = {}, tx .. "," .. ty + while prev[k] do + local p = prev[k] + table.insert(steps, 1, p[3]) + k = p[1] .. "," .. p[2] + end + return steps + end + -- a far cell around `want` steps away, away from `avoid` if given + local function pickTarget(dist, sx, sy, want, avoid) + local best, bestScore + for k, d in pairs(dist) do + if d >= want - 4 and d <= want + 4 then + local x, y = k:match("(-?%d+),(-?%d+)") + x, y = tonumber(x), tonumber(y) + local score = math.abs(x - sx) + math.abs(y - sy) + if avoid then score = score + 0.5 * (math.abs(x - avoid[1]) + math.abs(y - avoid[2])) end + if not bestScore or score > bestScore then best, bestScore = { x, y }, score end + end + end + return best + end + local rec = { dir = nil, f = 0, n = 0 } + local function tick() + rec.f = rec.f + 1 + if rec.dir and rec.f % STEP == 0 then + rec.n = rec.n + 1 + capture(rec.dir, rec.n) + end + coroutine.yield() + end + local function hold(btn, frames) + for _ = 1, frames do + table.insert(game.input.pressQueue, btn) + game.input.state[btn] = true + tick() + end + game.input.state[btn] = false + end + -- walk to (tx, ty) along the planned path, one tile at a time + local function walkTo(tx, ty, budget) + local p = game.overworld.player + local tries = 0 + while budget > 0 and (p.cellX ~= tx or p.cellY ~= ty) and tries < 6 do + local _, prev = bfs(p.cellX, p.cellY) + local steps = pathTo(prev, p.cellX, p.cellY, tx, ty) + if #steps == 0 then return budget end + tries = tries + 1 + for _, d in ipairs(steps) do + local wx, wy = p.cellX + DIRS[d][1], p.cellY + DIRS[d][2] + local t = 0 + while (p.cellX ~= wx or p.cellY ~= wy) and t < 48 and budget > 0 do + table.insert(game.input.pressQueue, d) + game.input.state[d] = true + tick(); t = t + 1; budget = budget - 1 + end + game.input.state[d] = false + if p.cellX ~= wx or p.cellY ~= wy then break end -- blocked: plan again + if budget <= 0 then return 0 end + end + end + return budget + end + -- a tour of about `frames` frames: a far cell, then a second leg elsewhere + local function strollRecord(shot, frames) + rec.dir, rec.f, rec.n = newShotDir(shot), 0, 0 + frames = frames or 420 + local p = game.overworld.player + local start = { p.cellX, p.cellY } + local budget = frames + strict = 7 + local n = 0 + for _ in pairs(bfs(p.cellX, p.cellY)) do n = n + 1 end + if n < 40 then strict = 5 end + n = 0 + for _ in pairs(bfs(p.cellX, p.cellY)) do n = n + 1 end + if n < 40 then strict = 0 end + for leg = 1, 3 do + if budget <= 0 then break end + local dist = bfs(p.cellX, p.cellY) + local target = pickTarget(dist, p.cellX, p.cellY, leg == 1 and 14 or 10, start) + if not target then break end + budget = walkTo(target[1], target[2], budget) + end + while budget > 0 do tick(); budget = budget - 1 end + U.log(("recorded %s: %d frames -> %s"):format(shot, rec.n, rec.dir)) + rec.dir = nil + end + -- first / third person: the free camera takes its yaw from the player's + -- facing at the moment the mode engages, and "up" then means forward. So + -- face the longest clear straight line FIRST, switch the mode on, and hold + -- forward. + local function longestLine() + local p = game.overworld.player + local bestDir, bestLen = "up", 0 + for name, v in pairs(DIRS) do + local len = 0 + while len < 30 and free(p.cellX + v[1] * (len + 1), p.cellY + v[2] * (len + 1)) do len = len + 1 end + if len > bestLen then bestDir, bestLen = name, len end + end + return bestDir, bestLen + end + local function forwardRecord(shot, frames, level) + local V = rawget(_G, "__DRAMATIC_SHAPE_V") + local okF, FP = pcall(function() return V and V.require("FirstPerson") end) + local okM, FM = pcall(function() return V and V.require("FreeMove") end) + if not (okF and FP and okM and FM) then U.log("no free camera for " .. shot); return end + local p = game.overworld.player + -- a route with corners in it: a far open cell, then a second leg, kept + -- to cells with room on every side so the eye never brushes a wall + strict = "wide" + local reach = 0 + for _ in pairs(bfs(p.cellX, p.cellY)) do reach = reach + 1 end + if reach < 30 then strict = 5 end + local dist, prev = bfs(p.cellX, p.cellY) + local t1 = pickTarget(dist, p.cellX, p.cellY, 12) + local cells = {} + if t1 then + local cx, cy = p.cellX, p.cellY + for _, d in ipairs(pathTo(prev, cx, cy, t1[1], t1[2])) do + cx, cy = cx + DIRS[d][1], cy + DIRS[d][2] + cells[#cells + 1] = { cx, cy } + end + local dist2, prev2 = bfs(cx, cy) + local t2 = pickTarget(dist2, cx, cy, 10, { p.cellX, p.cellY }) + if t2 then + for _, d in ipairs(pathTo(prev2, cx, cy, t2[1], t2[2])) do + cx, cy = cx + DIRS[d][1], cy + DIRS[d][2] + cells[#cells + 1] = { cx, cy } + end + end + end + strict = 7 + -- only the corners: a straight run is one waypoint + local wps = {} + for i, c in ipairs(cells) do + local nx = cells[i + 1] + local pv = cells[i - 1] or { p.cellX, p.cellY } + if not nx or (nx[1] - c[1]) ~= (c[1] - pv[1]) or (nx[2] - c[2]) ~= (c[2] - pv[2]) then + wps[#wps + 1] = { c[1] * 16 + 8, c[2] * 16 + 8 } + end + end + -- face the first leg before the mode engages, so it starts looking ahead + if wps[1] then + local dx, dy = wps[1][1] - (p.px + 8), wps[1][2] - (p.py + 8) + p.facing = math.abs(dx) > math.abs(dy) and (dx > 0 and "right" or "left") or (dy > 0 and "down" or "up") + end + voxel(level) + U.wait(90) + -- a pre-roll: the camera starts behind the player, which can be inside + -- whatever they were standing in front of; record once they are walking + local pre = (level == 7) and 75 or 30 + rec.dir, rec.f, rec.n = nil, 0, 0 + local function wrap(a) return (a + math.pi) % (2 * math.pi) - math.pi end + local wi, stuck, lastX, lastZ = 1, 0, nil, nil + while rec.f < frames + pre do + if not rec.dir and rec.f >= pre then rec.dir, rec.n = newShotDir(shot), 0 end + local pos = FM._pos() + local x = pos and pos.x or (p.px + 8) + local z = pos and pos.z or (p.py + 8) + local w = wps[wi] + if w and math.abs(w[1] - x) + math.abs(w[2] - z) < 5 then wi = wi + 1; w = wps[wi] end + local walking = false + if w then + -- ease the look toward the next corner: fast enough to round it, + -- slow enough to read as a person turning their head + local want = math.atan2(w[1] - x, w[2] - z) + local diff = wrap(want - FP.yaw) + local turn = diff * 0.1 + local maxTurn = 2.4 / 60 + if turn > maxTurn then turn = maxTurn elseif turn < -maxTurn then turn = -maxTurn end + FP.yaw = wrap(FP.yaw + turn) + walking = math.abs(diff) < 0.45 -- a corner turns first, then goes + -- a body that has not moved in a second is stuck: skip the corner + if lastX and math.abs(x - lastX) + math.abs(z - lastZ) < 0.05 and walking then stuck = stuck + 1 else stuck = 0 end + if stuck > 60 then wi = wi + 1; stuck = 0 end + end + lastX, lastZ = x, z + game.input.state.up = walking + if walking then table.insert(game.input.pressQueue, "up") end + tick() + end + game.input.state.up = false + U.log(("recorded %s: %d frames (%d corners) -> %s"):format(shot, rec.n, #wps, rec.dir)) + rec.dir = nil + end + local WALK = 420 + + local function battleShot(shot, species, level, opts, extraFrames) + local id = speciesByName(species) or species + local ok, battle = pcall(BattleState.newWild, game, id, level, opts) + if not ok or not battle then U.log("battle failed: " .. tostring(battle)); return end + battle.onFinish = function() end + game.overworld:pushBattle(battle) + -- the wipe and the send-out, then a turn: A every 40 frames + local dir = newShotDir(shot) + local n = 0 + for f = 1, extraFrames or 900 do + if f > 200 and f % 40 == 0 then table.insert(game.input.pressQueue, "a") end + if f % STEP == 0 then + n = n + 1 + capture(dir, n) + end + coroutine.yield() + if f % 40 == 1 then game.input.state.a = false end + end + U.log(("recorded %s: %d frames -> %s"):format(shot, n, dir)) + while game.stack:top() and game.stack:top() ~= game.overworld do game.stack:pop() end + U.wait(20) + end + + + -- ------------------------------------------------------------- the shots + local SHOTS = {} + local function shot(name, fn) SHOTS[#SHOTS + 1] = { name = name, fn = fn } end + + -- flat for the first half, then the voxels switch on mid-walk + local function revealRecord(shotName, frames) + voxel(0) + rec.dir, rec.f, rec.n = newShotDir(shotName), 0, 0 + local p = game.overworld.player + local dist = bfs(p.cellX, p.cellY) + local target = pickTarget(dist, p.cellX, p.cellY, 18) + local half = math.floor(frames / 2) + local function step(n) while n > 0 do tick(); n = n - 1 end end + if target then + -- walk the first half flat, then switch and keep walking + local left = walkTo(target[1], target[2], half) + step(left) + voxel(VOX) + local dist2 = bfs(p.cellX, p.cellY) + local t2 = pickTarget(dist2, p.cellX, p.cellY, 10) + left = t2 and walkTo(t2[1], t2[2], frames - half) or (frames - half) + step(left) + else + step(half); voxel(VOX); step(frames - half) + end + U.log(("recorded %s: %d frames -> %s"):format(shotName, rec.n, rec.dir)) + rec.dir = nil + end + -- every ordinary shot: voxels on at the chosen level, then the stroll + local function town(name, mapId, x, y, hour, level) + shot(name, function() + pinnedHour = hour or 12 + goTo(mapId, x, y) + if version ~= "platinum" then voxel(level or VOX); U.wait(150) end + strollRecord(name, WALK) + end) + end + local function fight(name, mapId, species, level, opts, frames, hour) + shot(name, function() + pinnedHour = hour or 12 + goTo(mapId) + if version ~= "platinum" then voxel(VOX); U.wait(150) end + battleShot(name, species, level, opts, frames) + end) + end + local function person(name, mapId, level, hour) + shot(name, function() + pinnedHour = hour or 12 + goTo(mapId, mapId == "NEW_BARK_TOWN" and 9 or nil, mapId == "NEW_BARK_TOWN" and 8 or nil) + forwardRecord(name, 420, level) + end) + end + + if version == "crystal" or version == "gold" or version == "silver" then + shot("voxel_reveal", function() pinnedHour = 12; goTo("NEW_BARK_TOWN", 9, 8); revealRecord("voxel_reveal", 480) end) + town("newbark_day", "NEW_BARK_TOWN", 9, 8, 12) + town("newbark_night", "NEW_BARK_TOWN", 9, 8, 21) + town("cherrygrove_tiltshift", "CHERRYGROVE_CITY", nil, nil, 12, 3) + town("violet", "VIOLET_CITY", nil, nil, 10) + town("ecruteak_dusk", "ECRUTEAK_CITY", nil, nil, 18) + town("goldenrod", "GOLDENROD_CITY", nil, nil, 14) + town("overview", "VIOLET_CITY", nil, nil, 12, 1) + fight("battle_shiny", "ROUTE_29", "GYARADOS", 30, { shiny = true }, 1000, 12) + fight("battle_dusk", "ROUTE_29", "PIDGEOTTO", 18, nil, 1200, 17) + person("first_person", "NEW_BARK_TOWN", 6, 12) + person("third_person", "CHERRYGROVE_CITY", 7, 16) + elseif version == "firered" then + shot("voxel_reveal", function() pinnedHour = 12; goTo("MAP_G03_N00"); revealRecord("voxel_reveal", 480) end) + town("pallet", "MAP_G03_N00") + town("cerulean", "MAP_G03_N03") + fight("battle", "MAP_G03_N00", "PIDGEY", 12, nil, 1000) + person("third_person", "MAP_G03_N03", 7) + elseif version == "emerald" then + town("littleroot", "MAP_G00_N09") + town("slateport", "MAP_G00_N01") + town("lilycove", "MAP_G00_N05") + fight("battle", "MAP_G00_N09", "ZIGZAGOON", 10, nil, 1000) + person("first_person", "MAP_G00_N05", 6) + elseif version == "platinum" then + -- natively 3D; single screen, the bottom screen off + game.save.options.secondScreenMode = "off" + pcall(function() game:applyOptions() end) + -- the camera rung: 1 is the cartridge's own; 9 third person, 10 first + local Gen4Camera = require("src.render.Gen4Camera") + local function rung(i) game.save.options.gen4CameraTilt = i; pcall(Gen4Camera.setTilt, i) end + rung(1) + shot("third_person", function() goTo("C01"); rung(9); U.wait(60); strollRecord("third_person", WALK); rung(1) end) + town("twinleaf", "T01") + town("jubilife", "C01") + town("snowpoint", "C09") + town("eterna", "C04") + town("hearthome", "C05") + fight("battle", "R201", "STARLY", 8, nil, 1100) + end + + for _, s in ipairs(SHOTS) do + if next(ONLY) == nil or ONLY[s.name] then + local ok, err = pcall(s.fn) + if not ok then U.log("shot " .. s.name .. " failed: " .. tostring(err)) end + voxel(0) + end + end + os.date = realDate + love.timer.getTime, love.update = realGetTime, realUpdate + -- let the last screenshots land, then drain the writers + U.wait(4) + for _ in ipairs(writers) do queue:push("stop") end + for _, t in ipairs(writers) do t:wait() end + U.log("done") + love.event.quit() +end diff --git a/tools/trailer/capture_all.ps1 b/tools/trailer/capture_all.ps1 new file mode 100644 index 000000000..5a9d8d53d --- /dev/null +++ b/tools/trailer/capture_all.ps1 @@ -0,0 +1,45 @@ +# tools/trailer/capture_all.ps1 -- record every trailer shot for every game. +# +# powershell -ExecutionPolicy Bypass -File tools/trailer/capture_all.ps1 -Out G:\trailer-footage +# +# Each game is one LOVE run of tools/trailer/capture.lua (see its header). The +# voxel mod must be installed in the identity's data folder and enabled; every +# shot is recorded with it on. Output: \_.mp4, streamed +# straight into ffmpeg (-Format png writes image sequences instead). +param( + [string]$Out = "G:\Gen2Recomped\trailer_footage", + [string]$Identity = "trailer_cap", + [string]$Love = "C:\Program Files\LOVE\love.exe", + [string[]]$Versions = @("crystal", "firered", "emerald", "platinum"), + [int]$Voxel = 4, + [string]$Only = "", + [string]$Ffmpeg = "G:\Tools\ffmpeg-9.0.2-essentials_build\bin\ffmpeg.exe", + [string]$Format = "mp4" +) +$ErrorActionPreference = "Stop" +$repo = Resolve-Path (Join-Path $PSScriptRoot "..\..") +New-Item -ItemType Directory -Force $Out | Out-Null + +foreach ($v in $Versions) { + Write-Host "=== $v ===" + $env:OUT = $Out.Replace("\", "/") + $env:VOXEL = "$Voxel" + $env:ONLY = $Only + $env:FFMPEG = $Ffmpeg + $env:FORMAT = $Format + $env:POKEPORT_IDENTITY = $Identity + $env:POKEPORT_VERSION = $v + $env:POKEPORT_DRIVER = "tools/trailer/capture.lua" + Remove-Item Env:POKEPORT_NO_MODS -ErrorAction SilentlyContinue + $log = Join-Path $Out "$v.log" + Push-Location $repo + try { + # love.exe is a GUI-subsystem program: `&` would not wait for it + Start-Process -FilePath $Love -ArgumentList "." -WorkingDirectory $repo -Wait -NoNewWindow ` + -RedirectStandardOutput $log -RedirectStandardError "$log.err" + } finally { + Pop-Location + } + Select-String -Path $log -Pattern "\[driver\]" | ForEach-Object { $_.Line } +} +Write-Host "footage in $Out" diff --git a/tools/trailer/editor_tour.lua b/tools/trailer/editor_tour.lua new file mode 100644 index 000000000..b048ce977 --- /dev/null +++ b/tools/trailer/editor_tour.lua @@ -0,0 +1,1063 @@ +-- tools/trailer/editor_tour.lua -- MAP EDITOR SHOWCASE FOOTAGE, recorded from +-- the real editor with a drawn cursor clicking the real buttons. +-- +-- A POKEPORT_DRIVER: the game boots (POKEPORT_VERSION=crystal or platinum), +-- then the driver hands the frame loop to the map editor (tools/save-editor +-- App in "map" mode) and plays a scripted tour, streaming segments to MP4: +-- +-- crystal: ed_crystal_browse, ed_crystal_3d, ed_crystal_tiles, +-- ed_crystal_walkable, ed_crystal_npcs, ed_crystal_world +-- platinum: ed_plat_browse, ed_plat_place, ed_plat_change +-- +-- Clicks are real: the pointer is faked (love.mouse.getPosition / isDown) and +-- buttons are found by wrapping Kit.button/chip/stepper/text/textfield, which +-- record where each label was drawn this frame. Map cells are located by the +-- editor's own projection (3D: Viewport3D.pick swept over the viewport; 2D: +-- the inverse of Preview's cellAtScreen). +-- +-- NOTHING IS SAVED: the tour never presses SAVE EDITS, so the edits live only +-- in this run's memory. +-- +-- Env: OUT (required), FFMPEG, WIDTH/HEIGHT (default 1920x1080). + +return function(game) + local U = dofile("tests/drivers/util.lua") + local OUT = assert(os.getenv("OUT"), "set OUT") + local version = require("src.core.GameVersion").get() + local W, H = tonumber(os.getenv("WIDTH") or "1920"), tonumber(os.getenv("HEIGHT") or "1080") + pcall(love.window.setMode, W, H, { resizable = true, vsync = 0 }) + U.wait(10) + + -- ------------------------------------------------------------- the pointer + local mx, my, down = W * 0.55, H * 0.55, false + love.mouse.getPosition = function() return mx, my end + love.mouse.getX = function() return mx end + love.mouse.getY = function() return my end + love.mouse.isDown = function(b) return down and b == 1 end + love.mouse.setRelativeMode = function() end + love.mouse.setGrabbed = function() end + + -- ------------------------------------------------------ the editor, hosted + local fs = love.filesystem + fs.setRequirePath("tools/save-editor/?.lua;tools/save-editor/panels/?.lua;" .. fs.getRequirePath()) + local Kit = require("Kit") + local cur = { rects = {}, texts = {}, fields = {} } + local last = cur + local function wrapLabelled(name, argIndex) + local orig = Kit[name] + Kit[name] = function(...) + local a = { ... } + local label = a[argIndex] + if type(label) == "string" then + local l = cur.rects[label] or {} + l[#l + 1] = { a[1], a[2], a[3], a[4] } + cur.rects[label] = l + end + return orig(...) + end + end + wrapLabelled("button", 5); wrapLabelled("chip", 5); wrapLabelled("stepper", 5) + do + local orig = Kit.text + Kit.text = function(fontName, str, x, y, ...) + if type(str) == "string" then cur.texts[#cur.texts + 1] = { str, x, y } end + return orig(fontName, str, x, y, ...) + end + local origP = Kit.press + Kit.press = function(x, y, w, h, ...) + cur.presses = cur.presses or {} + cur.presses[#cur.presses + 1] = { x, y, w, h } + return origP(x, y, w, h, ...) + end + local origF = Kit.textfield + Kit.textfield = function(id, x, y, w, h, ...) + cur.fields[id] = { x, y, w, h } + return origF(id, x, y, w, h, ...) + end + end + + local App = require("App") + App.load(nil, { version = version, mode = "map", embedded = true, onClose = function() end }) + local S = App.getState() + local Preview = require("tools.map-editor.panels.Preview") + local Viewport3D = require("tools.map-editor.Viewport3D") + local History = require("tools.map-editor.History") + -- the 3D viewport's real rectangle: Preview only records its own for the + -- flat view, so take it from the draw call + local view3D = nil + do + local orig = Viewport3D.draw + Viewport3D.draw = function(S_, built, x, y, w, h, ...) + view3D = { x, y, w, h } + return orig(S_, built, x, y, w, h, ...) + end + end + local function viewRect() + if S.pv3DActive and view3D then return view3D[1], view3D[2], view3D[3], view3D[4] end + return S._pvViewX, S._pvViewY, S._pvViewW, S._pvViewH + end + + -- ------------------------------------------------------------- the overlay + local ripple, toast, toastT = nil, nil, 0 + local toastFont = love.graphics.newFont(30) + local function drawCursor() + local G = love.graphics + if ripple then + local t = ripple.t / 18 + G.setColor(1, 1, 1, 0.75 * (1 - t)) + G.setLineWidth(4) + G.circle("line", ripple.x, ripple.y, 10 + 34 * t) + G.setLineWidth(1) + end + G.push() + G.translate(mx, my) + G.scale(1.25) + local pts = { 0, 0, 0, 26, 7, 20, 12, 31, 17, 29, 12, 18, 21, 18 } + G.setColor(0, 0, 0, 0.35); G.push(); G.translate(2, 3); G.polygon("fill", { 0, 0, 0, 26, 7, 20, 12, 18, 21, 18 }); G.pop() + G.setColor(1, 1, 1, 1) + G.polygon("fill", { 0, 0, 0, 26, 7, 20, 12, 18, 21, 18 }) + G.polygon("fill", { 7, 20, 12, 31, 17, 29, 12, 18 }) + G.setColor(0, 0, 0, 1); G.setLineWidth(1.6) + G.polygon("line", pts) + G.setLineWidth(1) + G.pop() + if toast and toastT > 0 then + local a = math.min(1, toastT / 12) + local tw = toastFont:getWidth(toast) + 56 + local x, y = (W - tw) / 2, H - 150 + G.setColor(0.04, 0.06, 0.12, 0.88 * a) + G.rectangle("fill", x, y, tw, 60, 14, 14) + G.setColor(0.97, 0.82, 0.19, a) + G.setLineWidth(3); G.rectangle("line", x, y, tw, 60, 14, 14); G.setLineWidth(1) + G.setColor(1, 1, 1, a) + G.setFont(toastFont) + G.print(toast, x + 28, y + 12) + end + G.setColor(1, 1, 1, 1) + end + game.update = function(self, dt) App.update(dt) end + game.draw = function(self) + cur = { rects = {}, texts = {}, fields = {}, presses = {} } + App.draw() + last = cur + drawCursor() + end + + -- ------------------------------------------------------------- recording + local Rec = dofile("tools/trailer/recorder.lua")({ out = OUT, ffmpeg = os.getenv("FFMPEG") }) + -- a camera the tour has framed stays framed: an edit rebuilds the 3D view, + -- and the rebuild would otherwise re-centre it behind the drawer + local camLock = nil + local function tick() + if camLock and camLock.flat then + S.pvCamX, S.pvCamY, S.pvZoom = camLock.x, camLock.y, camLock.z + S._pvCenteredFor = S.mapId + elseif camLock and S.pv3DActive then + S.pvFocus = { x = camLock.f.x, y = camLock.f.y, z = camLock.f.z } + S.pvDist, S.pvYaw, S.pvAngle = camLock.d, camLock.yaw, camLock.angle + end + if ripple then ripple.t = ripple.t + 1; if ripple.t > 18 then ripple = nil end end + if toastT > 0 then toastT = toastT - 1 end + Rec.frame() + coroutine.yield() + end + local function wait(n) for _ = 1, n do tick() end end + local function say(text, frames) toast, toastT = text, frames or 90 end + + -- ------------------------------------------------------------- gestures + local function ease(t) return t < 0.5 and 2 * t * t or 1 - (-2 * t + 2) ^ 2 / 2 end + local function moveTo(x, y, frames) + frames = frames or 24 + local x0, y0 = mx, my + for i = 1, frames do + local t = ease(i / frames) + mx, my = x0 + (x - x0) * t, y0 + (y - y0) * t + tick() + end + end + local function find(label, pick) + local list = last.rects[label] + if not list or #list == 0 then return nil end + if pick then + for _, r in ipairs(list) do if pick(r) then return r end end + return nil + end + return list[1] + end + -- a UI click: the editor reads Kit.mouseClicked on the next draw + local function clickHere() + ripple = { x = mx, y = my, t = 0 } + App.mousepressed(mx, my, 1) + tick(); tick() + end + local function click(label, pick, settle) + local r = find(label, pick) + if not r then U.log("no button " .. label); return false end + moveTo(r[1] + r[3] / 2, r[2] + r[4] / 2) + wait(6) + clickHere() + wait(settle or 20) + return true + end + -- a click on the map: Preview polls the button and acts on a still release + local function mapClick(x, y, travel) + moveTo(x, y, travel or 18) + wait(4) + ripple = { x = mx, y = my, t = 0 } + down = true; tick(); tick(); tick() + down = false; tick(); tick() + end + local function typeText(str, per) + for ch in str:gmatch(".") do App.textinput(ch); wait(per or 5) end + end + local function clickField(id) + local r = last.fields[id] + if not r then U.log("no field " .. id); return false end + moveTo(r[1] + 60, r[2] + r[4] / 2) + wait(5); clickHere(); wait(8) + return true + end + local function textAt(pattern) + for _, t in ipairs(last.texts) do + if t[1]:find(pattern) then return t end + end + end + + -- where a cell is on screen + local cellPos = {} + local function sweep(maxX) + cellPos = {} + local vx, vy, vw, vh = viewRect() + if not vx then return end + maxX = maxX or (vx + vw) + if S.pv3DActive and S.pv3D then + local acc = {} + for y = vy + 6, vy + vh - 6, 8 do + for x = vx + 6, math.min(vx + vw, maxX) - 6, 8 do + local ok, cx, cy = pcall(Viewport3D.pick, S, S.pv3D, x, y, vx, vy, vw, vh) + if ok and cx then + local k = cx .. "," .. cy + local e = acc[k] or { 0, 0, 0, x, x, y, y } + e[1], e[2], e[3] = e[1] + x, e[2] + y, e[3] + 1 + e[4], e[5] = math.min(e[4], x), math.max(e[5], x) + e[6], e[7] = math.min(e[6], y), math.max(e[7], y) + acc[k] = e + end + end + end + -- the ray also lands on the empty ground plane around the map: only + -- cells of the map itself count + local def = S.data.maps[S.mapId] + local wc = (version == "platinum") and def.width or def.width * 2 + local hc = (version == "platinum") and def.height or def.height * 2 + -- and a pick off the edge of the map comes back CLAMPED to an edge + -- cell, which then owns a smear of screen: keep cells whose samples + -- span about one cell's worth of screen + local spans = {} + for _, e in pairs(acc) do spans[#spans + 1] = math.max(e[5] - e[4], e[7] - e[6]) end + table.sort(spans) + local typical = spans[math.max(1, math.floor(#spans / 2))] or 40 + for k, e in pairs(acc) do + local cx, cy = k:match("(-?%d+),(-?%d+)") + cx, cy = tonumber(cx), tonumber(cy) + local span = math.max(e[5] - e[4], e[7] - e[6]) + if e[3] >= 2 and cx >= 0 and cy >= 0 and cx < wc and cy < hc and span <= typical * 1.8 + 8 then + cellPos[k] = { e[1] / e[3], e[2] / e[3] } + end + end + else + local def = S.data.maps[S.mapId] + local z = S.pvZoom or 2 + local wc = (version == "platinum") and def.width or def.width * 2 + local hc = (version == "platinum") and def.height or def.height * 2 + for cy = 0, hc - 1 do + for cx = 0, wc - 1 do + local x = vx + (cx * 16 + 8 - (S.pvCamX or 0)) * z + local y = vy + Preview.groundY(S, cy * 16 + 8 - (S.pvCamY or 0)) * z + if x > vx + 8 and x < math.min(vx + vw, maxX) - 8 and y > vy + 8 and y < vy + vh - 8 then + cellPos[cx .. "," .. cy] = { x, y } + end + end + end + end + end + local function cellXY(cx, cy) return cellPos[cx .. "," .. cy] end + -- visible cells nearest a screen point + local function mapMiddle() + local sx, sy, n = 0, 0, 0 + for _, p in pairs(cellPos) do sx, sy, n = sx + p[1], sy + p[2], n + 1 end + if n == 0 then return W / 2, H / 2 end + return sx / n, sy / n + end + local function cellsNear(px, py, n) + local all = {} + for k, p in pairs(cellPos) do + local cx, cy = k:match("(-?%d+),(-?%d+)") + all[#all + 1] = { tonumber(cx), tonumber(cy), p[1], p[2], (p[1] - px) ^ 2 + (p[2] - py) ^ 2 } + end + table.sort(all, function(a, b) return a[5] < b[5] end) + local out = {} + for i = 1, math.min(n, #all) do out[i] = all[i] end + return out + end + local function drawerLeft() + local xs = find("x", function(r) return r[2] > 150 end) + local chip = find("WARPS", function(r) return r[3] < 160 end) + -- the drawer's chip row starts ~80px in from the drawer's own edge + if chip then return chip[1] - 100 end + if xs then return xs[1] - 800 end + return W + end + + -- glide the 3D camera so the whole map sits in what the drawer leaves + -- visible: frame it, push back by the hidden fraction, pan it across + local function fitVisible(frames) + if not (S.pv3DActive and S.pv3D and view3D) then return end + local vx, _, vw, vh = viewRect() + local f0 = S.pvFocus and { x = S.pvFocus.x, y = S.pvFocus.y, z = S.pvFocus.z } + local d0 = S.pvDist + Viewport3D.frame(S, S.pv3D) + local visW = math.max(200, math.min(vx + vw, drawerLeft()) - vx) + S.pvDist = S.pvDist * (vw / visW) * 0.95 + Viewport3D.pan(S, S.pv3D, (vx + visW / 2) - (vx + vw / 2), 0, vh) + local f1, d1 = S.pvFocus, S.pvDist + camLock = nil + local function lock() camLock = { f = f1, d = d1, yaw = S.pvYaw, angle = S.pvAngle } end + if not (f0 and d0) then lock(); return end + for i = 1, frames or 30 do + local t = ease(i / (frames or 30)) + S.pvFocus = { x = f0.x + (f1.x - f0.x) * t, y = f0.y + (f1.y - f0.y) * t, z = f0.z + (f1.z - f0.z) * t } + S.pvDist = d0 + (d1 - d0) * t + tick() + end + lock() + end + + local function orbit(frames, dyaw, dpitch) + camLock = nil + local y0, a0 = S.pvYaw or 0, S.pvAngle or 35 + for i = 1, frames do + local t = ease(i / frames) + S.pvYaw = y0 + dyaw * t + S.pvAngle = a0 + (dpitch or 0) * math.sin(t * math.pi) + S.pvViewName = nil + tick() + end + end + + -- first frame: the editor, settled + S.pvDiag = false + wait(20) + + -- swatches, in palette order: one press per block at BLOCK grain, + -- four (one per quadrant) at CELL grain + local function swatch(idx, quadrant) + local dl = drawerLeft() + local sq = {} + local head = textAt("^%d+ blocks%s+%-") + local top = head and head[3] + 8 or 0 + for _, r in ipairs(last.presses or {}) do + if r[1] > dl and r[2] > top and math.abs(r[3] - r[4]) < 1 and r[3] >= 16 then sq[#sq + 1] = r end + end + -- the palette may be scrolled: count a row's swatches, skip scrolled rows + local cols = 0 + for _, q in ipairs(sq) do if sq[1] and math.abs(q[2] - sq[1][2]) < 2 then cols = cols + 1 end end + local per = (S.tileGrain == "cell") and 4 or 1 + cols = math.max(1, cols / ((S.tileGrain == "cell") and 2 or 1)) + local at = idx - (S.tileScroll or 0) * cols + local r + if at >= 0 then + if S.tileGrain == "cell" then r = sq[at * 4 + (quadrant or 0) + 1] else r = sq[at + 1] end + end + local _ = per + if r then return r[1] + r[3] / 2, r[2] + r[4] / 2 end + end + local function pickSwatch(idx, quadrant, travel) + local x, y = swatch(idx, quadrant) + if not x then U.log("no swatch " .. idx); return end + moveTo(x, y, travel or 22); wait(6); clickHere(); wait(14) + end + + + -- ================================================================ CRYSTAL + if version ~= "platinum" then + Rec.start("ed_crystal_browse") + wait(30) + if clickField("pv-q") then typeText("NewBark", 6) end + wait(20) + local row = textAt("^NewBark") or textAt("NewBark") + if row then moveTo(row[2] + 60, row[3] + 8, 20) else moveTo(225, 400, 20) end + wait(4); ripple = { x = mx, y = my, t = 0 } + Preview.select(S, "NEW_BARK_TOWN") + S.pvDiag = false + wait(60) + click("+", function(r) return r[2] < 300 and r[1] > 1100 end, 40) + click("-", function(r) return r[2] < 300 and r[1] > 1100 end, 40) + click("3D", nil, 10) + S.pvDiag = false + wait(70) + Rec.stop() + + Rec.start("ed_crystal_3d") + say("3D VIEW - orbit, tilt, fly", 120) + moveTo(W * 0.42, H * 0.6, 20) + orbit(150, 0.9, 18) + orbit(120, -0.9, -10) + -- more room for the map: fold the area list away + click("<", function(r) return r[1] < 450 end, 30) + App.keypressed("home"); wait(20) + Rec.stop() + + Rec.start("ed_crystal_tiles") + click("TILES", nil, 30) + say("TILES - paint the ground itself", 100) + fitVisible(30) + wait(10) + + -- the map's own blocks, to aim at real ground rather than empty space + local Map = require("src.world.Map") + local def = S.data.maps[S.mapId] + local blocks = Map.blockArray(def) or {} + local bw, bh = def.width, def.height + local function blockAt(bx, by) return blocks[by * bw + bx + 1] end + local function blockScreen(bx, by) + local sx, sy, n = 0, 0, 0 + for dy = 0, 1 do for dx = 0, 1 do + local p = cellXY(bx * 2 + dx, by * 2 + dy) + if p then sx, sy, n = sx + p[1], sy + p[2], n + 1 end + end end + if n < 4 then return nil end + return sx / n, sy / n + end + local function cellScreen(cx, cy) local p = cellXY(cx, cy); if p then return p[1], p[2] end end + local counts = {} + for _, id in ipairs(blocks) do counts[id] = (counts[id] or 0) + 1 end + -- the path is the commonest block INSIDE the town (not the border) + local inner = {} + for by = 2, bh - 3 do for bx = 2, bw - 3 do + local id = blockAt(bx, by); inner[id] = (inner[id] or 0) + 1 + end end + local pathId, best = nil, 0 + for id, n in pairs(inner) do if n > best then pathId, best = id, n end end + -- the border's commonest block: the tree line + local edge = {} + for bx = 0, bw - 1 do for _, by in ipairs({ 0, bh - 1 }) do + local id = blockAt(bx, by); edge[id] = (edge[id] or 0) + 1 + end end + local treeId, tb = nil, 0 + for id, n in pairs(edge) do if n > tb then treeId, tb = id, n end end + + sweep(drawerLeft()) + + -- the palette's own zoom + click("-", function(r) return r[1] > drawerLeft() and r[3] < 40 end, 18) + click("+", function(r) return r[1] > drawerLeft() and r[3] < 40 end, 18) + + -- BLOCK 32px: flowers along the path + click("BLOCK 32px", nil, 15) + click("PAINT", nil, 10) + say("PAINT - 32px blocks", 90) + pickSwatch(4) + local run = {} + for by = math.floor(bh / 2), bh - 3 do + run = {} + for bx = 2, bw - 3 do + if blockAt(bx, by) == pathId and blockScreen(bx, by) then run[#run + 1] = { bx, by } else + if #run >= 4 then break end + run = {} + end + end + if #run >= 4 then break end + end + for i = 1, math.min(4, #run) do + local x, y = blockScreen(run[i][1], run[i][2]) + mapClick(x, y, i == 1 and 24 or 12) + end + wait(20) + + -- CELL 16px: a quarter of a block at a time + click("CELL 16px", nil, 15) + say("CELL - 16px detail, a quarter block at a time", 100) + pickSwatch(5, 0) + local usedRow = run[1] and run[1][2] or math.floor(bh / 2) + local cellsDone = 0 + for bx = 2, bw - 3 do + local by = usedRow - 2 + if by >= 1 and blockAt(bx, by) == pathId and cellsDone < 3 then + local x, y = cellScreen(bx * 2, by * 2 + 1) + if x then cellsDone = cellsDone + 1; mapClick(x, y, cellsDone == 1 and 24 or 12) end + end + end + wait(20) + + -- PICK: the eyedropper, then stamp what it picked + click("BLOCK 32px", nil, 10) + click("PICK", nil, 15) + say("PICK - grab any block straight off the map", 100) + local tx, ty + for by = 0, bh - 1 do + for bx = 0, bw - 1 do + if not tx and blockAt(bx, by) == treeId then tx, ty = blockScreen(bx, by) end + end + end + if tx then mapClick(tx, ty, 24) end + wait(15) + click("PAINT", nil, 10) + local stamped = 0 + for by = 2, bh - 3 do + for bx = 2, bw - 3 do + if stamped < 2 and blockAt(bx, by) == pathId and by ~= usedRow and by ~= usedRow - 2 and (bx + by) % 3 == 0 then + local x, y = blockScreen(bx, by) + if x then stamped = stamped + 1; mapClick(x, y, 20) end + end + end + end + wait(20) + + -- FILL: flood a lawn with water + click("FILL", nil, 15) + say("FILL - flood a whole area in one click", 100) + pickSwatch(7) + -- a lawn: a connected patch of one non-path block, 3 to 30 blocks big + local seen, lawn = {}, nil + for by = 1, bh - 2 do + for bx = 1, bw - 2 do + local id = blockAt(bx, by) + local k = by * bw + bx + if not seen[k] and id ~= pathId and id ~= treeId then + local stack, size, members = { { bx, by } }, 0, {} + seen[k] = true + while #stack > 0 do + local c = table.remove(stack) + size = size + 1; members[#members + 1] = c + for _, d in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }) do + local nx, ny = c[1] + d[1], c[2] + d[2] + local nk = ny * bw + nx + if nx >= 0 and ny >= 0 and nx < bw and ny < bh and not seen[nk] and blockAt(nx, ny) == id then + seen[nk] = true; stack[#stack + 1] = { nx, ny } + end + end + end + if size >= 3 and size <= 30 and (not lawn or size > lawn.size) then + lawn = { size = size, at = members[math.ceil(#members / 2)] } + end + end + end + end + if lawn then + local x, y = blockScreen(lawn.at[1], lawn.at[2]) + if x then mapClick(x, y, 24) end + end + wait(60) + say("Ctrl+Z - undo", 70) + wait(15); pcall(History.undo, S); wait(50) + say("Ctrl+Y - redo", 70) + wait(15); pcall(History.redo, S); wait(50) + + -- FROM: any tileset in the game, added to this map + say("FROM - borrow blocks from any tileset", 110) + local own = S.tileSource + local srcBtn = find(tostring(own)) + if srcBtn then + moveTo(srcBtn[1] + srcBtn[3] / 2, srcBtn[2] + srcBtn[4] / 2, 22); wait(6); clickHere(); wait(25) + end + local other + for label, list in pairs(last.rects) do + if not other and label:find("^Tileset") and label ~= own and not label:find("this map") then + if label:find("Kanto") or label:find("Port") or label:find("Lab") or label:find("Ruins") then other = label end + end + end + if not other then + for label in pairs(last.rects) do + if not other and label:find("^Tileset") and label ~= own and not label:find("this map") then other = label end + end + end + if other then + click(other, nil, 30) + local add = nil + for label in pairs(last.rects) do if label:find("^ADD ") then add = label end end + if add then click(add, nil, 30) end + click("PAINT", nil, 10) + pickSwatch(9) + for by = 2, bh - 3 do + for bx = bw - 3, 2, -1 do + if not tx or true then + if blockAt(bx, by) == pathId and by ~= usedRow and (bx + by) % 4 == 1 and stamped < 3 then + local x, y = blockScreen(bx, by) + if x then stamped = stamped + 1; mapClick(x, y, 22) end + end + end + end + end + wait(20) + click("MINE", nil, 25) + end + + -- COLOURS: every palette mode, previewed live + say("COLOURS - preview every colour mode", 110) + local colBtn + for label, list in pairs(last.rects) do + local r = list[1] + local ct = textAt("^COLOURS") + if ct and math.abs(r[2] - ct[3] + 7) < 14 and r[1] > ct[2] then colBtn = label end + end + local okPF, PaletteFX = pcall(require, "src.render.PaletteFX") + local startMode = okPF and PaletteFX.mode + if colBtn then + for _ = 1, 3 do + local r = find(colBtn) or find(colBtn) + if not r then break end + moveTo(r[1] + r[3] / 2, r[2] + r[4] / 2, 16); wait(6); clickHere(); wait(40) + for label, list in pairs(last.rects) do + local ct = textAt("^COLOURS") + if ct and math.abs(list[1][2] - ct[3] + 7) < 14 and list[1][1] > ct[2] then colBtn = label end + end + end + -- back to how it was, the way the button itself applies a mode + if okPF and startMode and PaletteFX.mode ~= startMode then + PaletteFX.mode = startMode + pcall(function() require("src.render.Assets").invalidate() end) + pcall(function() require("src.world.MapLoader").evict(S.mapId) end) + pcall(function() Preview.forgetSprites() end) + S.pv3DKey, S._pvCenteredFor = nil, S.mapId + end + local okP, PaletteFX = false, nil + if okP and PaletteFX.MODES then + for _ = 1, #PaletteFX.MODES do + if PaletteFX.mode == startMode then break end + local r = find(colBtn); if not r then break end + mx, my = r[1] + r[3] / 2, r[2] + r[4] / 2 + clickHere(); wait(4) + for label, list in pairs(last.rects) do + local ct = textAt("^COLOURS") + if ct and math.abs(list[1][2] - ct[3] + 7) < 14 and list[1][1] > ct[2] then colBtn = label end + end + end + end + end + wait(30) + Rec.stop() + + Rec.start("ed_crystal_walkable") + click("x", function(r) return r[2] > 150 end, 20) + -- the walkable overlay is drawn for the flat view: show it there + camLock = nil + click("2D", nil, 30) + click("WALKABLE", nil, 30) + wait(20) + say("WALKABLE - paint where the player can go", 110) + S.collMode = "wall" + sweep(drawerLeft()) + local centerX, centerY = mapMiddle() + local wv = cellsNear(centerX, centerY - 30, 30) + table.sort(wv, function(a, b) if a[2] ~= b[2] then return a[2] < b[2] end return a[1] < b[1] end) + local wrow = wv[math.floor(#wv / 2)] and wv[math.floor(#wv / 2)][2] + local n = 0 + for _, t in ipairs(wv) do + if t[2] == wrow and n < 5 then n = n + 1; mapClick(t[3], t[4], n == 1 and 22 or 10) end + end + wait(40) + -- the walls overlay off again before the 3D view + click("SHOW WALLS", nil, 20) + Rec.stop() + + Rec.start("ed_crystal_npcs") + click("x", function(r) return r[2] > 150 end, 20) + click("3D", nil, 20) + click("NPCs & ITEMS", nil, 20) + S.collShow = false + fitVisible(30) + say("NPCs & ITEMS - every person, sign and item", 110) + wait(100) + click("x", function(r) return r[2] > 150 end, 20) + click("WARPS", function(r) return r[3] > 150 end, 40) + say("WARPS - doors, stairs and map links", 100) + wait(100) + Rec.stop() + + Rec.start("ed_crystal_world") + click("x", function(r) return r[2] > 150 end, 20) + click("WORLD", nil, 30) + say("WORLD - every map, stitched together", 120) + wait(170) + Rec.stop() + + -- ----------------------------------------- IMPORT A MAP FROM ANOTHER GAME + local MapLoader = require("src.world.MapLoader") + local function findBy(test) + for label, list in pairs(last.rects) do + for _, r in ipairs(list) do if test(label, r) then return r, label end end + end + end + local function clickRect(r, settle) + if not r then return false end + moveTo(r[1] + r[3] / 2, r[2] + r[4] / 2, 22); wait(6); clickHere(); wait(settle or 20) + return true + end + local function settleField(key, str) wait(4); if S[key] ~= str then S[key] = str end end + + Rec.start("ed_crystal_import") + camLock = nil + -- out of the world view first: it floats over everything + if find("BACK TO MAP") then click("BACK TO MAP", nil, 25) end + S.pvWorld = false + -- the area list back out, for its NEW AREA button + clickRect(findBy(function(l, r) return l == ">" and r[1] < 120 end), 25) + click("+ NEW AREA", nil, 30) + say("NEW AREA - or copy one from another game", 120) + wait(20) + -- one button per imported game, sharing the row: with many games the + -- labels ellipsize away, so find RED's slot from the importer's own list + local RMI = require("tools.map-editor.RomMapImport") + local ready = RMI.readySources(S) + local slot + for i, r in ipairs(ready) do if r.version == "red" then slot = i end end + local create, cancel = find("CREATE"), find("CANCEL") + local copyT = textAt("^COPY A MAP FROM") + if slot and create and cancel and copyT then + local sc = Kit.scale or 1 + local left = create[1] + local inner = (cancel[1] + cancel[3]) - left + local n = #ready + local bw = (inner - (n - 1) * 6 * sc) / n + local bx = left + (slot - 1) * (bw + 6 * sc) + local by = copyT[3] + 16 * sc + clickRect({ bx, by, bw, 30 * sc }, 30) + end + if not S.romImport then + local session = RMI.open(S, "red") + if session then S.romImport, S.romQuery, S.romScroll, S.romRows, S.romRowsFor = session, "", 0, nil, nil end + wait(10) + end + if clickField("rom-q") then typeText("cerulean_cave_1f", 4); settleField("romQuery", "cerulean_cave_1f") end + wait(25) + -- the list rows are wide presses under the search field + local fieldR = last.fields["rom-q"] + local rowR + for _, r in ipairs(last.presses or {}) do + if fieldR and math.abs(r[1] - fieldR[1]) < 4 and r[2] > fieldR[2] + fieldR[4] + and (not rowR or r[2] < rowR[2]) then rowR = r end + end + local before = S.mapId + say("CERULEAN CAVE - straight out of Pokemon Red", 130) + clickRect(rowR, 40) + local caveId = (S.mapId ~= before) and S.mapId or nil + U.log("imported: " .. tostring(caveId) .. " notice: " .. tostring(S.warpNotice)) + wait(60) + Rec.stop() + + if caveId then + Rec.start("ed_crystal_cave3d") + say("Red's cave, inside Crystal - in 3D", 140) + if not S.pv3DActive then click("3D", nil, 30) end + S.pvDiag = false + App.keypressed("home"); wait(10) + fitVisible(30) + camLock = nil + orbit(150, 0.8, 14) + wait(20) + Rec.stop() + + -- ------------------------------------- A DOOR INTO IT FROM CERULEAN CITY + Rec.start("ed_crystal_link") + camLock = nil + click("2D", nil, 25) + say("LINK IT - a door in Cerulean City", 120) + S.pvQuery = "" + if clickField("pv-q") then typeText("Cerulean", 5); settleField("pvQuery", "Cerulean") end + wait(15) + local cityRow = textAt("^CeruleanCity$") or textAt("^CeruleanCity") + if cityRow then moveTo(cityRow[2] + 60, cityRow[3] + 8, 20) end + wait(4); ripple = { x = mx, y = my, t = 0 } + Preview.select(S, "CERULEAN_CITY") + S.pvDiag = false + wait(40) + S.newMapOpen = false + -- ---- the cave mouth: painted into a wall the player can walk up to + local cmap = MapLoader.load(S.data, "CERULEAN_CITY") + local cdef = S.data.maps.CERULEAN_CITY + local Map = require("src.world.Map") + local cblocks = Map.blockArray(cdef) or {} + local warpAt = {} + for _, w in ipairs(cdef.warps or {}) do warpAt[w.x .. "," .. w.y] = true end + local CAVE = 6 -- TilesetKanto: its bottom-left cell is a cave entrance + click("x", function(r) return r[2] > 150 end, 15) + -- the palette back on this map's own tileset, top of the list + S.tileSource, S.tileScroll, S.tileNotice, S.tilePickSrc = nil, 0, nil, nil + click("TILES", nil, 25) + wait(10) + sweep(drawerLeft()) + local cxm, cym = mapMiddle() + local best, bestD + for by = 1, cdef.height - 2 do + for bx = 1, cdef.width - 2 do + local cx, cy = bx * 2, by * 2 + 1 -- the block's bottom-left cell + local solid = not cmap:isWalkableCell(cx, cy) and not cmap:isWalkableCell(cx + 1, cy) + and not cmap:isWalkableCell(cx, cy - 1) + local approach = cmap:isWalkableCell(cx, cy + 1) and cmap:isWalkableCell(cx + 1, cy + 1) + local p = cellXY(cx, cy) + if solid and approach and p and not warpAt[cx .. "," .. cy] then + local dd = (p[1] - cxm) ^ 2 + (p[2] - cym) ^ 2 + if not bestD or dd < bestD then best, bestD = { bx, by }, dd end + end + end + end + local caveCell + if best then + say("PAINT A CAVE MOUTH - carved into the rocks", 120) + click("BLOCK 32px", nil, 10) + click("PAINT", nil, 10) + S.tileScroll = 0 + wait(4) + pickSwatch(CAVE) + if S.tilePick ~= CAVE then U.log("picked " .. tostring(S.tilePick) .. ", wanted " .. CAVE); S.tilePick, S.tilePickSrc = CAVE, nil end + local sx, sy = 0, 0 + for dy = 0, 1 do for dx = 0, 1 do + local p = cellXY(best[1] * 2 + dx, best[2] * 2 + dy); sx, sy = sx + p[1] / 4, sy + p[2] / 4 + end end + mapClick(sx, sy, 24) + wait(40) + caveCell = { best[1] * 2, best[2] * 2 + 1 } + else + U.log("no spot for a cave mouth") + end + -- ---- the warp, on the cave cell + click("x", function(r) return r[2] > 150 end, 15) + click("WARPS", function(r) return r[3] > 150 end, 25) + S.newMapOpen = false + sweep(drawerLeft()) + if caveCell then + local p = cellXY(caveCell[1], caveCell[2]) + if p then mapClick(p[1], p[2], 22); wait(12) end + end + say("+ WARP - on the cave mouth, into Red's cave", 120) + click("+ WARP", nil, 25) + clickRect(find("-- none --"), 20) + if clickField("wp-map") then typeText("cerulean_cave", 4); settleField("warpMapQuery", "cerulean_cave") end + wait(20) + clickRect(find(caveId, function(r) return r[1] > drawerLeft() end), 30) + say("LINK BACK - and the cave's exit leads here", 120) + click("LINK BACK", nil, 50) + U.log("door: " .. tostring(S.warpNotice)) + wait(40) + Rec.stop() + end + + -- ------------------------------------------------------------ MAP PACKS + Rec.start("ed_crystal_pack") + say("EXPORT - every edit becomes a map pack mod", 130) + click("EXPORT", function(r) return r[2] < 90 end, 30) + U.log("export: " .. tostring(S.pvNotice)) + say("exported -> MAP_EDITS_CRYSTAL.zip (drop it in mods/ to share)", 150) + wait(60) + wait(40) + click("PACKS", function(r) return r[2] < 90 end, 30) + say("PACKS - install, stack and share map packs", 130) + wait(110) + click("DONE", nil, 30) + wait(20) + Rec.stop() + end + + -- =============================================================== PLATINUM + if version == "platinum" then + local Models = require("tools.map-editor.panels.Models") + local MapLoader = require("src.world.MapLoader") + -- typing into a field: the keystrokes, and the value itself in case the + -- field took focus a frame late + local function typeInto(field, key, str) + clickField(field) + for ch in str:gmatch(".") do App.textinput(ch); wait(6) end + wait(4) + -- a field that took focus a frame late missed some keys: settle it + if S[key] ~= str then S[key] = str end + end + -- drawn is not the same as on screen: the drawer clips its body, so a + -- control below the fold is reported but cannot be clicked + local function onScreen(pattern) + for _, t in ipairs(last.texts) do + if t[1]:find(pattern) and t[3] > 160 and t[3] < H - 118 then return t end + end + end + local function scrollDrawerTo(pattern) + local dl = drawerLeft() + if mx < dl then moveTo(dl + 300, H * 0.7, 14) end + -- down first, then back up: the drawer keeps whatever scroll the last + -- view of it left, which can be past the control as easily as short of it + for _, dir in ipairs({ -1, 1 }) do + for _ = 1, 16 do + if onScreen(pattern) then return onScreen(pattern) end + App.wheelmoved(0, dir); wait(5) + end + end + return onScreen(pattern) + end + local function stepField(key, sign, times) + local label = scrollDrawerTo("^" .. key .. ": ") + if not label then U.log("no field " .. key); return end + local r = find(sign, function(rr) return rr[1] > drawerLeft() and math.abs((rr[2] + rr[4] / 2) - (label[3] + 7)) < 9 end) + if not r then U.log("no stepper for " .. key); return end + moveTo(r[1] + r[3] / 2, r[2] + r[4] / 2, 20) + for _ = 1, times do wait(9); clickHere() end + wait(15) + end + + Rec.start("ed_plat_browse") + wait(30) + if clickField("pv-q") then typeText("Jubilife", 6) end + wait(20) + local row = textAt("^Jubilife City") or textAt("Jubilife") + if row then moveTo(row[2] + 60, row[3] + 8, 20) end + wait(4); ripple = { x = mx, y = my, t = 0 } + Preview.select(S, "C01") + S.pvDiag = false + wait(50) + click("<", function(r) return r[1] < 450 end, 25) + say("Jubilife City, drawn the way the cartridge draws it", 140) + -- the view's own zoom, the rightmost + in the map header + local function zoomPlus() + local best + for _, r in ipairs(last.rects["+"] or {}) do + if r[2] < 300 and (not best or r[1] > best[1]) then best = r end + end + return best + end + for _ = 1, 1 do + local r = zoomPlus() + if r then moveTo(r[1] + r[3] / 2, r[2] + r[4] / 2, 22); wait(6); clickHere(); wait(40) end + end + wait(40) + Rec.stop() + + Rec.start("ed_plat_place") + click("3D PROPS", nil, 25) + say("3D PROPS - the real Platinum models", 110) + -- an open plaza: walkable, no props, near the middle of the city + local map = MapLoader.load(S.data, S.mapId) + local def = S.data.maps[S.mapId] + local function open(cx, cy) + for dy = -3, 3 do for dx = -3, 3 do + local x, y = cx + dx, cy + dy + if not (map:inBounds(x, y) and map:isWalkableCell(x, y)) then return false end + local okP, hits = pcall(Models.propsInCell, S, x, y) + if okP and hits and #hits > 0 then return false end + end end + return true + end + local spot + local mcx, mcy = math.floor(def.width / 2), math.floor(def.height / 2) + for r = 0, 24 do + for dy = -r, r do for dx = -r, r do + if not spot and (math.abs(dx) == r or math.abs(dy) == r) and open(mcx + dx, mcy + dy) then spot = { mcx + dx, mcy + dy } end + end end + if spot then break end + end + spot = spot or { mcx, mcy } + -- centre the plaza in what the drawer leaves visible, gliding there + camLock = nil + local vx, vy, vw, vh = viewRect() + local z = S.pvZoom or 2 + local visW = math.max(200, math.min(vx + vw, drawerLeft()) - vx) + local tx = spot[1] * 16 + 8 - visW / (2 * z) + local ty = spot[2] * 16 + 8 - Preview.groundUn(S, vh / (2 * z)) + local x0, y0 = S.pvCamX or tx, S.pvCamY or ty + for i = 1, 40 do + local t = ease(i / 40) + S.pvCamX, S.pvCamY = x0 + (tx - x0) * t, y0 + (ty - y0) * t + S._pvCenteredFor = S.mapId + tick() + end + camLock = { flat = true, x = tx, y = ty, z = z } + wait(15) + + -- pick a tree from the model list + click("Add model...", nil, 8) + pcall(function() require("tools.map-editor.BodyFill").reset(S, "models") end) + wait(15) + typeInto("model-q", "modelQuery", "tree") + wait(25) + local pickRow = textAt("%d+ tree01") or textAt("%d+ tree") + if pickRow then moveTo(pickRow[2] + 80, pickRow[3] + 8, 22); wait(8); clickHere(); wait(20) end + if not Models.armed(S) then + local ground = map.renderer and map.renderer.gen4Ground + local m = ground and (Models.catalogueMatching(ground, "tree01")[1] or Models.catalogueMatching(ground, "tree")[1]) + if m then Models.arm(S, m.member) end + S.modelPickerOpen = false + end + say("click the map to place it", 100) + sweep(drawerLeft()) + local targets = { { spot[1] - 2, spot[2] }, { spot[1] + 2, spot[2] - 1 }, { spot[1], spot[2] + 2 } } + local placedIdx = {} + for i, t in ipairs(targets) do + local p = cellXY(t[1], t[2]) + if p then + mapClick(p[1], p[2], i == 1 and 24 or 16) + placedIdx[#placedIdx + 1] = S.modelPick + wait(24) + end + end + Models.disarm(S) + wait(30) + Rec.stop() + + Rec.start("ed_plat_change") + -- select the first tree by clicking it + local first = targets[1] + local p1 = cellXY(first[1], first[2]) + if p1 then mapClick(p1[1], p1[2], 22); wait(20) end + if placedIdx[1] and S.modelPick ~= placedIdx[1] then S.modelPick = placedIdx[1] end + -- a new prop starts at y = 0; this city's ground is y = 32 + say("Y - lift it up onto the ground", 110) + stepField("y", "+", 4) + wait(20) + say("X and Z - slide it across the plaza", 110) + stepField("x", "+", 5) + stepField("z", "+", 4) + wait(20) + say("CHANGE - swap it for any of 590 models", 120) + local ch = scrollDrawerTo("^model: ") + local chb = ch and find("Change...", function(r) return r[2] > 160 and r[2] < H - 60 end) + if chb then + S.modelQuery = "" + moveTo(chb[1] + chb[3] / 2, chb[2] + chb[4] / 2, 22); wait(6); clickHere(); wait(6) + -- the picker opens at the drawer's old scroll: back to its top + pcall(function() require("tools.map-editor.BodyFill").reset(S, "models") end) + wait(18) + typeInto("model-q", "modelQuery", "pc") + wait(25) + local r2 = onScreen("%d+ pc$") or onScreen("%d+ pc") + if r2 then moveTo(r2[2] + 80, r2[3] + 8, 22); wait(8); clickHere(); wait(40) end + end + say("SCALE - any size", 90) + stepField("scaleX", "+", 3) + stepField("scaleY", "+", 3) + wait(40) + do + local vals = {} + for _, k in ipairs({ "model", "x", "y", "z", "scaleX", "scaleY" }) do + local t = textAt("^" .. k .. ": ") + vals[#vals + 1] = t and t[1] or (k .. ":?") + end + U.log("prop now: " .. table.concat(vals, " | ")) + end + -- and take one of the trees away again + if placedIdx[2] then + say("REMOVE - and take one away again", 120) + -- back up to the list, pick the second tree's row, remove it + for _ = 1, 16 do + if onScreen("^%s*" .. placedIdx[2] .. " model") and find("Remove prop", function(r) return r[2] > 160 end) then break end + App.wheelmoved(0, 1); wait(5) + end + local rowT = onScreen("^%s*" .. placedIdx[2] .. " model") + if rowT then moveTo(rowT[2] + 120, rowT[3] + 8, 22); wait(6); clickHere(); wait(15) end + if S.modelPick ~= placedIdx[2] then S.modelPick = placedIdx[2] end + click("Remove prop", function(r) return r[2] > 160 end, 50) + end + wait(40) + Rec.stop() + end + + wait(6) + Rec.finish() + love.event.quit() +end diff --git a/tools/trailer/recorder.lua b/tools/trailer/recorder.lua new file mode 100644 index 000000000..135a2b124 --- /dev/null +++ b/tools/trailer/recorder.lua @@ -0,0 +1,80 @@ +-- tools/trailer/recorder.lua -- stream rendered frames into ffmpeg, one MP4 per +-- segment, off the main thread. The same writer capture.lua carries inline, +-- as a module for drivers that record something other than the overworld. +-- +-- local Rec = dofile("tools/trailer/recorder.lua")({ out = OUT, ffmpeg = FFMPEG }) +-- Rec.start("editor_open") -- later frames go to /editor_open.mp4 +-- Rec.frame() -- call once per logic step; keeps every STEPth +-- Rec.stop() -- segment over; the next start opens a new file +-- Rec.finish() -- drain and close ffmpeg (end of the run) +-- +-- Also pins love.timer.getTime to a 1/60-per-update frame clock, so animation +-- in the footage is even however slowly the window renders. +return function(opts) + opts = opts or {} + local OUT = assert(opts.out, "recorder: out folder") + local FFMPEG = opts.ffmpeg or "ffmpeg" + local CRF = tostring(opts.crf or 14) + local STEP = opts.step or 2 + + local realGetTime = love.timer.getTime + local clock = realGetTime() + local realUpdate = love.update + love.update = function(dt) clock = clock + 1 / 60; return realUpdate(dt) end + love.timer.getTime = function() return clock end + + local WRITER = [[ + local ch, ffmpeg, crf, fps = ... + require("love.image") + local pipe, current + local function close() + if pipe then pipe:close(); pipe = nil end + current = nil + end + while true do + local job = ch:demand() + if job == "stop" then close(); return end + if job.path ~= current then + close() + local w, h = job.img:getDimensions() + local cmd = ('"%s" -y -loglevel error -f rawvideo -pix_fmt rgba -s %dx%d -r %d -i - ' + .. '-c:v libx264 -preset slow -crf %s -pix_fmt yuv420p -movflags +faststart "%s.mp4"') + :format(ffmpeg, w, h, fps, crf, job.path) + if package.config:sub(1, 1) == "\\" then cmd = '"' .. cmd .. '"' end + pipe = io.popen(cmd, "wb") + current = job.path + end + if pipe then pipe:write(job.img:getString()) end + job.img:release() + end + ]] + local queue = love.thread.getChannel("recorder_frames") + local thread = love.thread.newThread(WRITER) + thread:start(queue, FFMPEG, CRF, math.floor(60 / STEP)) + + local R = { frames = 0 } + local path, f = nil, 0 + function R.start(name) + path, f, R.frames = OUT .. "/" .. name, 0, 0 + end + function R.stop() + if path then print(("[recorder] %s: %d frames"):format(path, R.frames)) end + path = nil + end + function R.frame() + if not path then return end + f = f + 1 + if f % STEP ~= 0 then return end + R.frames = R.frames + 1 + while queue:getCount() > 24 do realGetTime(); love.timer.sleep(0.004) end + local p = path + love.graphics.captureScreenshot(function(img) queue:push({ path = p, img = img }) end) + end + function R.finish() + R.stop() + queue:push("stop") + thread:wait() + love.timer.getTime, love.update = realGetTime, realUpdate + end + return R +end diff --git a/tools/trailer/remotion/.gitignore b/tools/trailer/remotion/.gitignore new file mode 100644 index 000000000..1abd3a210 --- /dev/null +++ b/tools/trailer/remotion/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +out/ +public/footage/ diff --git a/tools/trailer/remotion/package-lock.json b/tools/trailer/remotion/package-lock.json new file mode 100644 index 000000000..f8d356002 --- /dev/null +++ b/tools/trailer/remotion/package-lock.json @@ -0,0 +1,3939 @@ +{ + "name": "gen2recomped-trailer", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gen2recomped-trailer", + "dependencies": { + "@remotion/cli": "^4.0.534", + "@remotion/google-fonts": "^4.0.534", + "@remotion/transitions": "^4.0.534", + "@types/react": "^18.3.31", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "remotion": "^4.0.534", + "typescript": "^5.9.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.1.tgz", + "integrity": "sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg==", + "license": "MIT", + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template/node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mediabunny/aac-encoder": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@mediabunny/aac-encoder/-/aac-encoder-1.56.1.tgz", + "integrity": "sha512-a+OY/BctP7zC4G/prHikooOVaoWXMD4+bUyEwHg2dtQiQdIwC6TDJdVYJu5dfrdSNOiAFkv1HKcYEeROdwMezg==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@mediabunny/flac-encoder": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@mediabunny/flac-encoder/-/flac-encoder-1.56.1.tgz", + "integrity": "sha512-zbmorMVlF1izwtlz5mWnu7A1rJLM+V26I0pEFr9Td1EDTwE5uAGRSW+/UZlnjwj1t00Woe3Zonfh9kb7lRRBqg==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@mediabunny/mp3-encoder": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@mediabunny/mp3-encoder/-/mp3-encoder-1.56.1.tgz", + "integrity": "sha512-p66ovEq6mZt3ejje45NOju84hIJI4v8rHfsmc1D2J34ziV0USE8AdJBzmiwqxl6nZkIAQLNB19HzbVRcQfc51g==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@module-federation/error-codes": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.22.0.tgz", + "integrity": "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug==", + "license": "MIT" + }, + "node_modules/@module-federation/runtime": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-0.22.0.tgz", + "integrity": "sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA==", + "license": "MIT", + "dependencies": { + "@module-federation/error-codes": "0.22.0", + "@module-federation/runtime-core": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@module-federation/runtime-core": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-0.22.0.tgz", + "integrity": "sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA==", + "license": "MIT", + "dependencies": { + "@module-federation/error-codes": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@module-federation/runtime-tools": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-0.22.0.tgz", + "integrity": "sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA==", + "license": "MIT", + "dependencies": { + "@module-federation/runtime": "0.22.0", + "@module-federation/webpack-bundler-runtime": "0.22.0" + } + }, + "node_modules/@module-federation/sdk": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-0.22.0.tgz", + "integrity": "sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g==", + "license": "MIT" + }, + "node_modules/@module-federation/webpack-bundler-runtime": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.22.0.tgz", + "integrity": "sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA==", + "license": "MIT", + "dependencies": { + "@module-federation/runtime": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", + "integrity": "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@remotion/bundler": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/bundler/-/bundler-4.0.534.tgz", + "integrity": "sha512-8PdxU94g8Y0RErUYzQFzEbPBwBU6x1QwVRCL+vqr/sW+LrEZvWrqpMxp6QPkRUpIGA+8yWhYXl8R3D72xY8qEg==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@remotion/media-parser": "4.0.534", + "@remotion/studio": "4.0.534", + "@remotion/studio-shared": "4.0.534", + "@remotion/timeline-utils": "4.0.534", + "@rspack/core": "1.7.11", + "@rspack/plugin-react-refresh": "1.6.1", + "css-loader": "7.1.4", + "esbuild": "0.28.1", + "react-refresh": "0.18.0", + "remotion": "4.0.534", + "style-loader": "4.0.0", + "webpack": "5.105.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/captions": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/captions/-/captions-4.0.534.tgz", + "integrity": "sha512-Auxsj2ZuvYKiljZ7g/FwWArZbwR7XvkIseDmT4UE0Vfj+9c57//hjNGp4JFn2uKIv7BeHN8zxOi0W0xUzVd2lA==", + "license": "MIT" + }, + "node_modules/@remotion/cli": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/cli/-/cli-4.0.534.tgz", + "integrity": "sha512-Eqqbg98XqisuetU4OMezj11G6Gesn9hRZrGjQoSpJNVIHfgzbR4deqVj5yAbddaDmmlsHBEFVfdOjayE2q+Yww==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@babel/parser": "7.24.1", + "@remotion/bundler": "4.0.534", + "@remotion/codemods": "4.0.534", + "@remotion/media-utils": "4.0.534", + "@remotion/player": "4.0.534", + "@remotion/renderer": "4.0.534", + "@remotion/studio": "4.0.534", + "@remotion/studio-server": "4.0.534", + "@remotion/studio-shared": "4.0.534", + "dotenv": "17.3.1", + "mediabunny": "1.56.1", + "minimist": "1.2.6", + "prompts": "2.4.2", + "remotion": "4.0.534", + "semver": "7.5.3" + }, + "bin": { + "remotion": "remotion-cli.js", + "remotionb": "remotionb-cli.js", + "remotiond": "remotiond-cli.js" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/codemods": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/codemods/-/codemods-4.0.534.tgz", + "integrity": "sha512-TDu9NTIQZafUF+qAQcLlIuud4fvghiiQGtvcFJ5yk3s8elBPamEiP8nwhbApsSL5c8Pz6ztGIJcfKcPR9Q5gWg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "7.24.1", + "@babel/types": "7.24.0", + "@remotion/studio-protocol": "4.0.534", + "@remotion/studio-shared": "4.0.534", + "ast-types": "0.16.1", + "recast": "0.23.21", + "remotion": "4.0.534" + } + }, + "node_modules/@remotion/compositor-darwin-arm64": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/compositor-darwin-arm64/-/compositor-darwin-arm64-4.0.534.tgz", + "integrity": "sha512-q/FW5u8KaA/VIks8kPpOByIuJGaxH6myHG3Xyx5qAfxhiBjbimp2WEYmF09GivBdHp3CT/f5RrRLT+8Jrpi1ow==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@remotion/compositor-darwin-x64": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/compositor-darwin-x64/-/compositor-darwin-x64-4.0.534.tgz", + "integrity": "sha512-yz5FtNA2k7Q1i8DBkHAMoBFkH+mCfwdYTxIQYROyZ640vDlZoXTqcDjKq0NLO1y/dGDpjpVWbYHjH1u4D+Dqeg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@remotion/compositor-linux-arm64-gnu": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-arm64-gnu/-/compositor-linux-arm64-gnu-4.0.534.tgz", + "integrity": "sha512-7yeVbpaSdgymw1Fs4xm08ZOI55oL2Uq0WyJrqYIhQIbg6d7Ecd6ePNbPyaeQTIa9Dq5Rxay+qbF/DV3pqOb68w==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-linux-arm64-musl": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-arm64-musl/-/compositor-linux-arm64-musl-4.0.534.tgz", + "integrity": "sha512-rJd/ZPEvQaLgh6ULy9svPwQ9QRNW2jX03PkX8heoIC1JUh196XupQ9QMRTMOFNz/Fu7yWgyVbXxQVkRtJb1EvA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-linux-x64-gnu": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-x64-gnu/-/compositor-linux-x64-gnu-4.0.534.tgz", + "integrity": "sha512-o7vW9vld+PmbjazKJl66sYdJZxCxW/adQd3zozV355Vb5NVqZmV8BHvSuZ1Xv210r0Vj0qtEv50ig2x0nAPmLA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-linux-x64-musl": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-x64-musl/-/compositor-linux-x64-musl-4.0.534.tgz", + "integrity": "sha512-6uMmkP143LF/AwjdU997xdwg7o7NCdb2eEtwt+/1qYK8/HTkd6KupqLFLBevuIQVyJ06iCwmUhZSxNL9tbWHoQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-win32-x64-msvc": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/compositor-win32-x64-msvc/-/compositor-win32-x64-msvc-4.0.534.tgz", + "integrity": "sha512-+hp7c5ZAnomRe4U+s9tG36kmjMo0V43xV7WG353QCCHLRjKiR5KdkrU1TJQOAq53oyLdsD7BHUkEEWIBSSzSLg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@remotion/elevenlabs": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/elevenlabs/-/elevenlabs-4.0.534.tgz", + "integrity": "sha512-TPd1XFSUfausKxeIljPqWOslJpeYARAeZFT2TSmvrmQBpz3i8yoG96gk3qXkqj7ebzQAqzv75bzd8jegwRU1Gw==", + "license": "MIT", + "dependencies": { + "@remotion/captions": "4.0.534", + "zod": "4.5.4" + } + }, + "node_modules/@remotion/google-fonts": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/google-fonts/-/google-fonts-4.0.534.tgz", + "integrity": "sha512-bgXF3QJ5Mkjmnaz1ROm1UfeP0jCJpkv4y6EPoisIVtexqVgwk9Zmp1dMemE81ok38zDLHTqM0/+5msBKWuhg3A==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "remotion": "4.0.534" + } + }, + "node_modules/@remotion/licensing": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/licensing/-/licensing-4.0.534.tgz", + "integrity": "sha512-pNP8OfY+vbDbjNBLYGoxGyE0SsReRy85G/QTF17mQC86dt2+pzsYlOkG2mq6R4tkt27xaQv8O5EFiJfYYfVkkw==", + "license": "MIT" + }, + "node_modules/@remotion/media": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/media/-/media-4.0.534.tgz", + "integrity": "sha512-xdipJG5ApdwIeu5YS1+iYzUxKybFwsKIjYKPtM5ywBUZiAiaBwK8nMiGRU73xchiLygBlxCpnPQ33a7L3+btMg==", + "dependencies": { + "mediabunny": "1.56.1", + "remotion": "4.0.534", + "zod": "4.5.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/media-parser": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/media-parser/-/media-parser-4.0.534.tgz", + "integrity": "sha512-FE/Ww9wBOrxiMCqGwHWbPXZBxs27WzDCDhOxkYyxnOSzSePzYseDrDdwuqCGApr1cDxiwkAm91EcqvP5acPziQ==", + "license": "Remotion License https://remotion.dev/license" + }, + "node_modules/@remotion/media-utils": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/media-utils/-/media-utils-4.0.534.tgz", + "integrity": "sha512-ANj7kfYvO/IL4kGxfG4Cv14J45jgpoKHQ0zrYEZoGdiaiLeM/Hj+L3WyNqTySyVS3n6RzU+aqBMDiijOPpOMtQ==", + "license": "MIT", + "dependencies": { + "mediabunny": "1.56.1", + "remotion": "4.0.534" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/openai-whisper": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/openai-whisper/-/openai-whisper-4.0.534.tgz", + "integrity": "sha512-YKUkbL7PXTtlIwiBEDD1k/3cqMJXTKh9T8jRvIr/q6Bjbv26Ed0FfAd6ZY3kKX/3x4ozlHF7OTOG/3HMBgKZeA==", + "license": "MIT", + "dependencies": { + "@remotion/captions": "4.0.534", + "zod": "4.5.4" + } + }, + "node_modules/@remotion/paths": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/paths/-/paths-4.0.534.tgz", + "integrity": "sha512-bc/kgZrpadDK95zc/qZ0x7RFN5R2p6/NceIKhLY8jhCLx6SL3BxAprBaNAFmOPX4JQ42uZp1qMAdzw7JI0GCPQ==", + "license": "MIT", + "dependencies": { + "remotion": "4.0.534" + } + }, + "node_modules/@remotion/player": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/player/-/player-4.0.534.tgz", + "integrity": "sha512-uXkg9qsuYP85eykXTayr8jjSzFJYwtPzYprMfjxnAx9u5C/H726wTE800wGUcDPCDn1SPf6TAg71pmo882Z+jA==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "remotion": "4.0.534" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/renderer": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/renderer/-/renderer-4.0.534.tgz", + "integrity": "sha512-CLZiNRibavsEpDWSm1WsD//xLGCUA/OwhFektjiAbRNkX75Ee3bOV4cjpyihaEd0HaiUv3bUT0QLnvdwynor/w==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@remotion/licensing": "4.0.534", + "@remotion/streaming": "4.0.534", + "execa": "5.1.1", + "remotion": "4.0.534", + "source-map": "0.8.0", + "ws": "8.21.0" + }, + "optionalDependencies": { + "@remotion/compositor-darwin-arm64": "4.0.534", + "@remotion/compositor-darwin-x64": "4.0.534", + "@remotion/compositor-linux-arm64-gnu": "4.0.534", + "@remotion/compositor-linux-arm64-musl": "4.0.534", + "@remotion/compositor-linux-x64-gnu": "4.0.534", + "@remotion/compositor-linux-x64-musl": "4.0.534", + "@remotion/compositor-win32-x64-msvc": "4.0.534" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/sdk": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/sdk/-/sdk-4.0.534.tgz", + "integrity": "sha512-XMh394qeaEsbo6Q2HCaxx4uYBauNutMiQiFwBvMnIzjRr7j+SRKSv8x4d7RWlAWDTdlEeithNgwSJxm0lXgwVQ==", + "license": "Remotion License", + "dependencies": { + "@remotion/player": "4.0.534", + "@remotion/studio-shared": "4.0.534", + "remotion": "4.0.534" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/shapes": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/shapes/-/shapes-4.0.534.tgz", + "integrity": "sha512-RejGlDRB+oy2+hKylGVdHSFbX2LwwbbEjc27QXbzA7dWj3D3jNsWLROR4k5GCqo9jfjuHtyW0Ue3ZE6SD0eFBA==", + "license": "MIT", + "dependencies": { + "@remotion/paths": "4.0.534", + "remotion": "4.0.534" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/streaming": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/streaming/-/streaming-4.0.534.tgz", + "integrity": "sha512-GE7wbEbQH15Ul4wCJYv81bjpbVIBKTxkcfCJgtYV8kiydG8v0OduYRyeUk1NTAAm80MK1oGjPOqabwRMxFGOfg==", + "license": "MIT" + }, + "node_modules/@remotion/studio": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/studio/-/studio-4.0.534.tgz", + "integrity": "sha512-sWteZLvzTMCAvpqVcXvZ7PF9gbadK7KL8RsTFf+diyHs4g1lB2Enb4vuyd7qkdFOBctE0BG4K/Srcd0Mej/axw==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@remotion/captions": "4.0.534", + "@remotion/codemods": "4.0.534", + "@remotion/elevenlabs": "4.0.534", + "@remotion/media": "4.0.534", + "@remotion/media-utils": "4.0.534", + "@remotion/openai-whisper": "4.0.534", + "@remotion/paths": "4.0.534", + "@remotion/player": "4.0.534", + "@remotion/renderer": "4.0.534", + "@remotion/sdk": "4.0.534", + "@remotion/studio-protocol": "4.0.534", + "@remotion/studio-shared": "4.0.534", + "@remotion/timeline-utils": "4.0.534", + "@remotion/web-renderer": "4.0.534", + "@remotion/zod-types": "4.0.534", + "@tanstack/react-virtual": "3.14.9", + "mediabunny": "1.56.1", + "memfs": "3.4.3", + "open": "8.4.2", + "prismjs": "1.30.0", + "remotion": "4.0.534", + "semver": "7.5.3", + "zod": "4.5.4" + }, + "peerDependencies": { + "@remotion/video-matting": "4.0.534", + "@remotion/whisper-webgpu": "4.0.534", + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@remotion/video-matting": { + "optional": true + }, + "@remotion/whisper-webgpu": { + "optional": true + } + } + }, + "node_modules/@remotion/studio-protocol": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/studio-protocol/-/studio-protocol-4.0.534.tgz", + "integrity": "sha512-bNUlqQS3iU64FXJKOfb+irlC3U6H3pAIDEWUvyqza05KYtSF26rrR93ogtOMvBbP0aDtXFwLAOjtsH0xj4KvqQ==", + "license": "Remotion License", + "dependencies": { + "zod": "4.5.4" + } + }, + "node_modules/@remotion/studio-server": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/studio-server/-/studio-server-4.0.534.tgz", + "integrity": "sha512-lEA2XaEH99HJYXrpbyO2n2SI0o1iw2Daua9eEYwBwTDBGGc5bngmaS2lkAfTO4rcW3c2aqxqNkBQEKzIXPcIeQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "7.24.1", + "@babel/types": "7.24.0", + "@remotion/bundler": "4.0.534", + "@remotion/codemods": "4.0.534", + "@remotion/renderer": "4.0.534", + "@remotion/studio-protocol": "4.0.534", + "@remotion/studio-shared": "4.0.534", + "@svgr/core": "8.1.0", + "@svgr/plugin-jsx": "8.1.0", + "kiwi-schema": "0.5.0", + "memfs": "3.4.3", + "open": "8.4.2", + "recast": "0.23.21", + "remotion": "4.0.534", + "semver": "7.5.3", + "zod": "4.5.4" + } + }, + "node_modules/@remotion/studio-shared": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/studio-shared/-/studio-shared-4.0.534.tgz", + "integrity": "sha512-0lLHhyN7xJZGB/MnCIDHaSVp4/Oarf/M+xflr6z3Lh1UGBU9TD5V6IWAwn/hcMuXxQWs/3VSRvwZXPtsBPCpTQ==", + "license": "MIT", + "dependencies": { + "remotion": "4.0.534" + } + }, + "node_modules/@remotion/timeline-utils": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/timeline-utils/-/timeline-utils-4.0.534.tgz", + "integrity": "sha512-4qx/e4LV/FurqLBZKbOte18zi9OzRDQE4aa+uuUvxAwnil7mN8Usg+suamPYogwq6pZ1+ZjuMbB7pQqqAU1KLQ==", + "license": "MIT", + "dependencies": { + "mediabunny": "1.56.1", + "remotion": "4.0.534" + } + }, + "node_modules/@remotion/transitions": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/transitions/-/transitions-4.0.534.tgz", + "integrity": "sha512-JGy0sFYJ3aubtOyy+dXwoOGlW1NeP0sN3BslwcDF4pa+yIvzhl57wD2F9bj86pEgEF03ng9gjwiIH/cBjxqL7A==", + "license": "UNLICENSED", + "dependencies": { + "@remotion/paths": "4.0.534", + "@remotion/shapes": "4.0.534", + "remotion": "4.0.534" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/web-renderer": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/web-renderer/-/web-renderer-4.0.534.tgz", + "integrity": "sha512-5JlbHvU4eAA/ymvHLmHHvR+YfkTMnVcInwaNVu2kLyXGEO6HcRmqDwp2hhAGtj212/LvZKKLPLBXbfhHygelIQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@mediabunny/aac-encoder": "1.56.1", + "@mediabunny/flac-encoder": "1.56.1", + "@mediabunny/mp3-encoder": "1.56.1", + "@remotion/licensing": "4.0.534", + "mediabunny": "1.56.1", + "remotion": "4.0.534" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@remotion/zod-types": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/@remotion/zod-types/-/zod-types-4.0.534.tgz", + "integrity": "sha512-WYIHdVFGrOBIX7v7v5GIJ1LCjv8oyruJD1sl06ZpDBfMzJspSTQrXH2hoT37YZcA+RUpyUomHkQrdiqxk/Jxgg==", + "license": "MIT", + "dependencies": { + "remotion": "4.0.534" + } + }, + "node_modules/@rspack/binding": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-1.7.11.tgz", + "integrity": "sha512-2MGdy2s2HimsDT444Bp5XnALzNRxuBNc7y0JzyuqKbHBywd4x2NeXyhWXXoxufaCFu5PBc9Qq9jyfjW2Aeh06Q==", + "license": "MIT", + "optionalDependencies": { + "@rspack/binding-darwin-arm64": "1.7.11", + "@rspack/binding-darwin-x64": "1.7.11", + "@rspack/binding-linux-arm64-gnu": "1.7.11", + "@rspack/binding-linux-arm64-musl": "1.7.11", + "@rspack/binding-linux-x64-gnu": "1.7.11", + "@rspack/binding-linux-x64-musl": "1.7.11", + "@rspack/binding-wasm32-wasi": "1.7.11", + "@rspack/binding-win32-arm64-msvc": "1.7.11", + "@rspack/binding-win32-ia32-msvc": "1.7.11", + "@rspack/binding-win32-x64-msvc": "1.7.11" + } + }, + "node_modules/@rspack/binding-darwin-arm64": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.7.11.tgz", + "integrity": "sha512-oduECiZVqbO5zlVw+q7Vy65sJFth99fWPTyucwvLJJtJkPL5n17Uiql2cYP6Ijn0pkqtf1SXgK8WjiKLG5bIig==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rspack/binding-darwin-x64": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-1.7.11.tgz", + "integrity": "sha512-a1+TtTE9ap6RalgFi7FGIgkJP6O4Vy6ctv+9WGJy53E4kuqHR0RygzaiVxCI/GMc/vBT9vY23hyrpWb3d1vtXA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rspack/binding-linux-arm64-gnu": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.7.11.tgz", + "integrity": "sha512-P0QrGRPbTWu6RKWfN0bDtbnEps3rXH0MWIMreZABoUrVmNQKtXR6e73J3ub6a+di5s2+K0M2LJ9Bh2/H4UsDUA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-arm64-musl": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.7.11.tgz", + "integrity": "sha512-6ky7R43VMjWwmx3Yx7Jl7faLBBMAgMDt+/bN35RgwjiPgsIByz65EwytUVuW9rikB43BGHvA/eqlnjLrUzNBqw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-x64-gnu": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.7.11.tgz", + "integrity": "sha512-cuOJMfCOvb2Wgsry5enXJ3iT1FGUjdPqtGUBVupQlEG4ntSYsQ2PtF4wIDVasR3wdxC5nQbipOrDiN/u6fYsdQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-x64-musl": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-1.7.11.tgz", + "integrity": "sha512-CoK37hva4AmHGh3VCsQXmGr40L36m1/AdnN5LEjUX6kx5rEH7/1nEBN6Ii72pejqDVvk9anEROmPDiPw10tpFg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-wasm32-wasi": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-1.7.11.tgz", + "integrity": "sha512-OtrmnPUVJMxjNa3eDMfHyPdtlLRmmp/aIm0fQHlAOATbZvlGm12q7rhPW5BXTu1yh+1rQ1/uqvz+SzKEZXuJaQ==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "1.0.7" + } + }, + "node_modules/@rspack/binding-win32-arm64-msvc": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.7.11.tgz", + "integrity": "sha512-lObFW6e5lCWNgTBNwT//yiEDbsxm9QG4BYUojqeXxothuzJ/L6ibXz6+gLMvbOvLGV3nKgkXmx8GvT9WDKR0mA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/binding-win32-ia32-msvc": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.7.11.tgz", + "integrity": "sha512-0pYGnZd8PPqNR68zQ8skamqNAXEA1sUfXuAdYcknIIRq2wsbiwFzIc0Pov1cIfHYab37G7sSIPBiOUdOWF5Ivw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/binding-win32-x64-msvc": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.7.11.tgz", + "integrity": "sha512-EeQXayoQk/uBkI3pdoXfQBXNIUrADq56L3s/DFyM2pJeUDrWmhfIw2UFIGkYPTMSCo8F2JcdcGM32FGJrSnU0Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/core": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/core/-/core-1.7.11.tgz", + "integrity": "sha512-rsD9b+Khmot5DwCMiB3cqTQo53ioPG3M/A7BySu8+0+RS7GCxKm+Z+mtsjtG/vsu4Tn2tcqCdZtA3pgLoJB+ew==", + "license": "MIT", + "dependencies": { + "@module-federation/runtime-tools": "0.22.0", + "@rspack/binding": "1.7.11", + "@rspack/lite-tapable": "1.1.0" + }, + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.1" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@rspack/lite-tapable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rspack/lite-tapable/-/lite-tapable-1.1.0.tgz", + "integrity": "sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw==", + "license": "MIT" + }, + "node_modules/@rspack/plugin-react-refresh": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@rspack/plugin-react-refresh/-/plugin-react-refresh-1.6.1.tgz", + "integrity": "sha512-eqqW5645VG3CzGzFgNg5HqNdHVXY+567PGjtDhhrM8t67caxmsSzRmT5qfoEIfBcGgFkH9vEg7kzXwmCYQdQDw==", + "license": "MIT", + "dependencies": { + "error-stack-parser": "^2.1.4", + "html-entities": "^2.6.0" + }, + "peerDependencies": { + "react-refresh": ">=0.10.0 <1.0.0", + "webpack-hot-middleware": "2.x" + }, + "peerDependenciesMeta": { + "webpack-hot-middleware": { + "optional": true + } + } + }, + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", + "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", + "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", + "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", + "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", + "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", + "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-preset": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz", + "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", + "license": "MIT", + "dependencies": { + "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0", + "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0", + "@svgr/babel-plugin-svg-dynamic-title": "8.0.0", + "@svgr/babel-plugin-svg-em-dimensions": "8.0.0", + "@svgr/babel-plugin-transform-react-native-svg": "8.1.0", + "@svgr/babel-plugin-transform-svg-component": "8.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/core": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", + "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "camelcase": "^6.2.0", + "cosmiconfig": "^8.1.3", + "snake-case": "^3.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/hast-util-to-babel-ast": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", + "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.21.3", + "entities": "^4.4.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/plugin-jsx": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", + "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "@svgr/hast-util-to-babel-ast": "8.0.0", + "svg-parser": "^2.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "*" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.9", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.9.tgz", + "integrity": "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.7", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.7.tgz", + "integrity": "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/dom-mediacapture-transform": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.12.tgz", + "integrity": "sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw==", + "license": "MIT", + "dependencies": { + "@types/dom-webcodecs": "*" + } + }, + "node_modules/@types/dom-webcodecs": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz", + "integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==", + "license": "MIT" + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "license": "Apache-2.0" + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001815", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", + "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-loader": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.4.tgz", + "integrity": "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.40", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.6.3" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", + "webpack": "^5.27.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/css-loader/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.452", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz", + "integrity": "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==", + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fs-monkey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz", + "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==", + "license": "Unlicense" + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause" + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kiwi-schema": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/kiwi-schema/-/kiwi-schema-0.5.0.tgz", + "integrity": "sha512-X+FpfU0yTEtc6aTHS7VwbOpvQwRt70+pXXWRI5fd6CvWhe7pSVC854TVo4Zo0x5/wwcWj+/9KUlXpdcP0dY9AA==", + "license": "MIT", + "bin": { + "kiwic": "cli.js" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/mediabunny": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.56.1.tgz", + "integrity": "sha512-DNOm0bRBmeOxJgCK/g3LxNWbxY5morTauNdlychujGmJ9L37ezVezkNoI1WtO2Xdb4WlWWJlYfIKnOEzd5phRw==", + "license": "MPL-2.0", + "workspaces": [ + ".", + "packages/*" + ], + "dependencies": { + "@types/dom-mediacapture-transform": "^0.1.11", + "@types/dom-webcodecs": "0.1.13" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + } + }, + "node_modules/memfs": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.3.tgz", + "integrity": "sha512-eivjfi7Ahr6eQTn44nvTnR60e4a1Fs1Via2kCR5lHo/kyNoiMWaXCNJ/GpSd0ilXas2JSOl9B5FTIhflXu0hlg==", + "license": "Unlicense", + "dependencies": { + "fs-monkey": "1.0.3" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimist": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT" + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/node-releases": { + "version": "2.0.58", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz", + "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", + "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/recast": { + "version": "0.23.21", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.21.tgz", + "integrity": "sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/recast/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/remotion": { + "version": "4.0.534", + "resolved": "https://registry.npmjs.org/remotion/-/remotion-4.0.534.tgz", + "integrity": "sha512-gSfMreFJgncWEHGsIWksicmEGsTrGMIAgZ87WS4e+bIB+nVsAN67UXWbQ88xGwUONm3xzwu9Ty786kFDcgZoaQ==", + "license": "SEE LICENSE IN LICENSE.md", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/schema-utils": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.5.0.tgz", + "integrity": "sha512-zJlMCZ0cAR5p/Y4oVpRoqioDMJcGxaXRrQ/4rP4WyR84vc5z/DolXdbvXeDpTwbtocDFr2rhPqHPErDCUtz2kA==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/semver": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", + "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/snake-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", + "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/source-map": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0.tgz", + "integrity": "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT" + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/style-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", + "integrity": "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==", + "license": "MIT", + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.27.0" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/svg-parser": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.1.0.tgz", + "integrity": "sha512-bwLf38YmY+TDYHJw1Ex0Co8c4yeXuJAo8YnXGZrscxrvYoVVIvLeniEkV1Ks/54VteMnL4FtyN1+P+TZJdUPmQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.51.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz", + "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==", + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/watchpack": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack": { + "version": "5.105.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", + "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", + "license": "MIT", + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.19.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.3.1", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.16", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.6.0.tgz", + "integrity": "sha512-EIMmPVvNI0CYXyRPp34F2Qk7W37/BZVZIofS6LAUnrNjCVahS+hO4mxwFJDjQjODTGZDtVzLYoN2+1dYkhk9qA==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/tools/trailer/remotion/package.json b/tools/trailer/remotion/package.json new file mode 100644 index 000000000..a5b53a348 --- /dev/null +++ b/tools/trailer/remotion/package.json @@ -0,0 +1,18 @@ +{ + "name": "gen2recomped-trailer", + "private": true, + "scripts": { + "studio": "remotion studio src/index.ts", + "render": "remotion render src/index.ts Trailer out/trailer.mp4 --crf 16 --pixel-format yuv420p" + }, + "dependencies": { + "@remotion/cli": "^4.0.534", + "@remotion/google-fonts": "^4.0.534", + "@remotion/transitions": "^4.0.534", + "@types/react": "^18.3.31", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "remotion": "^4.0.534", + "typescript": "^5.9.3" + } +} diff --git a/tools/trailer/remotion/public/editor/ed_crystal_3d.mp4 b/tools/trailer/remotion/public/editor/ed_crystal_3d.mp4 new file mode 100644 index 000000000..6850ec827 Binary files /dev/null and b/tools/trailer/remotion/public/editor/ed_crystal_3d.mp4 differ diff --git a/tools/trailer/remotion/public/editor/ed_crystal_browse.mp4 b/tools/trailer/remotion/public/editor/ed_crystal_browse.mp4 new file mode 100644 index 000000000..97a516007 Binary files /dev/null and b/tools/trailer/remotion/public/editor/ed_crystal_browse.mp4 differ diff --git a/tools/trailer/remotion/public/editor/ed_crystal_cave3d.mp4 b/tools/trailer/remotion/public/editor/ed_crystal_cave3d.mp4 new file mode 100644 index 000000000..f5c64b8ca Binary files /dev/null and b/tools/trailer/remotion/public/editor/ed_crystal_cave3d.mp4 differ diff --git a/tools/trailer/remotion/public/editor/ed_crystal_import.mp4 b/tools/trailer/remotion/public/editor/ed_crystal_import.mp4 new file mode 100644 index 000000000..514b980aa Binary files /dev/null and b/tools/trailer/remotion/public/editor/ed_crystal_import.mp4 differ diff --git a/tools/trailer/remotion/public/editor/ed_crystal_link.mp4 b/tools/trailer/remotion/public/editor/ed_crystal_link.mp4 new file mode 100644 index 000000000..6b4de377e Binary files /dev/null and b/tools/trailer/remotion/public/editor/ed_crystal_link.mp4 differ diff --git a/tools/trailer/remotion/public/editor/ed_crystal_npcs.mp4 b/tools/trailer/remotion/public/editor/ed_crystal_npcs.mp4 new file mode 100644 index 000000000..ce9cbfa97 Binary files /dev/null and b/tools/trailer/remotion/public/editor/ed_crystal_npcs.mp4 differ diff --git a/tools/trailer/remotion/public/editor/ed_crystal_pack.mp4 b/tools/trailer/remotion/public/editor/ed_crystal_pack.mp4 new file mode 100644 index 000000000..a89e171a9 Binary files /dev/null and b/tools/trailer/remotion/public/editor/ed_crystal_pack.mp4 differ diff --git a/tools/trailer/remotion/public/editor/ed_crystal_tiles.mp4 b/tools/trailer/remotion/public/editor/ed_crystal_tiles.mp4 new file mode 100644 index 000000000..e572eef48 Binary files /dev/null and b/tools/trailer/remotion/public/editor/ed_crystal_tiles.mp4 differ diff --git a/tools/trailer/remotion/public/editor/ed_crystal_walkable.mp4 b/tools/trailer/remotion/public/editor/ed_crystal_walkable.mp4 new file mode 100644 index 000000000..8a08ea1e9 Binary files /dev/null and b/tools/trailer/remotion/public/editor/ed_crystal_walkable.mp4 differ diff --git a/tools/trailer/remotion/public/editor/ed_crystal_world.mp4 b/tools/trailer/remotion/public/editor/ed_crystal_world.mp4 new file mode 100644 index 000000000..f9b6a5df0 Binary files /dev/null and b/tools/trailer/remotion/public/editor/ed_crystal_world.mp4 differ diff --git a/tools/trailer/remotion/public/editor/ed_plat_browse.mp4 b/tools/trailer/remotion/public/editor/ed_plat_browse.mp4 new file mode 100644 index 000000000..fb6e51a4d Binary files /dev/null and b/tools/trailer/remotion/public/editor/ed_plat_browse.mp4 differ diff --git a/tools/trailer/remotion/public/editor/ed_plat_change.mp4 b/tools/trailer/remotion/public/editor/ed_plat_change.mp4 new file mode 100644 index 000000000..32d6ebe97 Binary files /dev/null and b/tools/trailer/remotion/public/editor/ed_plat_change.mp4 differ diff --git a/tools/trailer/remotion/public/editor/ed_plat_place.mp4 b/tools/trailer/remotion/public/editor/ed_plat_place.mp4 new file mode 100644 index 000000000..acec68e17 Binary files /dev/null and b/tools/trailer/remotion/public/editor/ed_plat_place.mp4 differ diff --git a/tools/trailer/remotion/public/logo.png b/tools/trailer/remotion/public/logo.png new file mode 100644 index 000000000..fa0cfac16 Binary files /dev/null and b/tools/trailer/remotion/public/logo.png differ diff --git a/tools/trailer/remotion/src/MapEditorVideo.tsx b/tools/trailer/remotion/src/MapEditorVideo.tsx new file mode 100644 index 000000000..82af43b3b --- /dev/null +++ b/tools/trailer/remotion/src/MapEditorVideo.tsx @@ -0,0 +1,179 @@ +// The map editor showcase, cut from tools/trailer/editor_tour.lua's footage +// (public/editor is a junction to G:\Gen2Recomped\editor_footage). Every +// click in it is a real click on the real editor; the on-screen labels are +// drawn by the tour itself. +// +// npx remotion render src/index.ts MapEditor out/map_editor.mp4 +import React from "react"; +import { + AbsoluteFill, + Audio, + interpolate, + OffthreadVideo, + Sequence, + spring, + staticFile, + useCurrentFrame, + useVideoConfig, +} from "remotion"; +import { Card, GOLD, Logo, PIXEL, SANS, useEdgeFade } from "./Trailer"; + +const MUSIC = false; +const FPS = 30; +const s = (sec: number) => Math.round(sec * FPS); + +// clip lengths as recorded (seconds) +const CLIPS = { + ed_crystal_browse: 7.8, + ed_crystal_3d: 6.2, + ed_crystal_tiles: 30.0, + ed_crystal_walkable: 6.5, + ed_crystal_npcs: 8.5, + ed_crystal_world: 4.7, + ed_crystal_import: 8.5, + ed_crystal_cave3d: 3.5, + ed_crystal_link: 18.0, + ed_crystal_pack: 6.9, + ed_plat_browse: 6.3, + ed_plat_place: 8.2, + ed_plat_change: 15.8, +} as const; +type ClipName = keyof typeof CLIPS; + +const Clip: React.FC<{ name: ClipName; dur: number; label?: string }> = ({ name, dur, label }) => { + const f = useCurrentFrame(); + const opacity = useEdgeFade(dur, 8); + const scale = interpolate(f, [0, dur], [1, 1.02]); + return ( + + + {label ? : null} + + ); +}; + +// a small section chip, top-left, over the editor's title bar +const Chip: React.FC<{ text: string }> = ({ text }) => { + const f = useCurrentFrame(); + const { fps } = useVideoConfig(); + const p = spring({ frame: f, fps, config: { damping: 200 } }); + return ( +
+ {text} +
+ ); +}; + +const SectionCard: React.FC<{ dur: number; gen: string; game: string; sub: string }> = ({ dur, gen, game, sub }) => { + const f = useCurrentFrame(); + const { fps } = useVideoConfig(); + const p = spring({ frame: f, fps, config: { damping: 14 } }); + return ( + +
{gen}
+
+ {game} +
+
{sub}
+
+ ); +}; + +type Beat = { len: number; el: React.ReactNode }; +const clip = (name: ClipName, label?: string): Beat => { + const len = s(CLIPS[name]); + return { len, el: }; +}; + +const beats: Beat[] = [ + { + len: s(4), + el: ( + + +
MAP EDITOR
+
+ Every map. Every tile. Every model. Built in. +
+
+ ), + }, + { len: s(2.5), el: }, + clip("ed_crystal_browse", "PICK ANY MAP"), + clip("ed_crystal_3d", "EDIT IN 3D"), + clip("ed_crystal_tiles", "TILES"), + clip("ed_crystal_walkable", "WALKABLE"), + clip("ed_crystal_npcs", "NPCs & WARPS"), + clip("ed_crystal_world", "WORLD MAP"), + { len: s(2.5), el: }, + clip("ed_crystal_import", "IMPORT FROM RED"), + clip("ed_crystal_cave3d", "CERULEAN CAVE"), + clip("ed_crystal_link", "CAVE MOUTH + WARP"), + clip("ed_crystal_pack", "EXPORT A MAP PACK"), + { len: s(2.5), el: }, + clip("ed_plat_browse", "JUBILIFE CITY"), + clip("ed_plat_place", "PLACE MODELS"), + clip("ed_plat_change", "MOVE · CHANGE · SCALE"), + { + len: s(7), + el: ( + + +
+ Export your edits as a map pack and share them as a mod. +
+
discord.gg/4VXnEnePT
+
Rip packs with us on Tilt Rips:
+
tiltrips.com/r/GEN2RECOMP
+
+ ), + }, +]; + +export const MAP_EDITOR_FRAMES = beats.reduce((n, b) => n + b.len, 0); + +export const MapEditorVideo: React.FC = () => { + let t = 0; + return ( + + {beats.map((b, i) => { + const from = t; + t += b.len; + return ( + + {b.el} + + ); + })} + {MUSIC ? + ); +}; diff --git a/tools/trailer/remotion/src/Root.tsx b/tools/trailer/remotion/src/Root.tsx new file mode 100644 index 000000000..1113ca4e6 --- /dev/null +++ b/tools/trailer/remotion/src/Root.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { Composition } from "remotion"; +import { Trailer, TRAILER_FRAMES } from "./Trailer"; +import { MapEditorVideo, MAP_EDITOR_FRAMES } from "./MapEditorVideo"; + +export const RemotionRoot: React.FC = () => ( + <> + + + +); diff --git a/tools/trailer/remotion/src/Trailer.tsx b/tools/trailer/remotion/src/Trailer.tsx new file mode 100644 index 000000000..5461da06f --- /dev/null +++ b/tools/trailer/remotion/src/Trailer.tsx @@ -0,0 +1,459 @@ +// The Gen2Recomped trailer, cut from the harness footage in public/footage +// (a junction to G:\Gen2Recomped\trailer_footage, one MP4 per shot, recorded +// by tools/trailer/capture_all.ps1). The beat sheet is tools/trailer/TRAILER.md. +// +// npm run studio -- preview and scrub +// npm run render -- out/trailer.mp4 +// +// Optional music: drop public/music.mp3 and set MUSIC = true. +import React from "react"; +import { + AbsoluteFill, + Audio, + Easing, + Img, + interpolate, + OffthreadVideo, + Sequence, + spring, + staticFile, + useCurrentFrame, + useVideoConfig, +} from "remotion"; +import { loadFont as loadPixel } from "@remotion/google-fonts/PressStart2P"; +import { loadFont as loadSans } from "@remotion/google-fonts/Inter"; + +export const { fontFamily: PIXEL } = loadPixel(); +export const { fontFamily: SANS } = loadSans("normal", { weights: ["500", "600", "700"], subsets: ["latin"] }); + +const MUSIC = false; +const FPS = 30; +const s = (sec: number) => Math.round(sec * FPS); + +export const GOLD = "#f8d030"; +export const INK = "#0b0f1a"; + +// ----------------------------------------------------------- building blocks + +// a fade in and out on the edges of a clip +export const useEdgeFade = (dur: number, edge = 8) => { + const f = useCurrentFrame(); + return interpolate(f, [0, edge, dur - edge, dur], [0, 1, 1, 0], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); +}; + +// one shot of game footage, with a slow push-in so even a still beat moves +const Shot: React.FC<{ src: string; dur: number; from?: number; push?: number; fade?: number }> = ({ + src, + dur, + from = 0, + push = 0.04, + fade = 8, +}) => { + const f = useCurrentFrame(); + const opacity = useEdgeFade(dur, fade); + const scale = interpolate(f, [0, dur], [1, 1 + push]); + return ( + + + + ); +}; + +// a kinetic caption: slides up, holds, fades +const Caption: React.FC<{ text: string; sub?: string; dur: number; pos?: "low" | "mid" | "top" }> = ({ + text, + sub, + dur, + pos = "low", +}) => { + const f = useCurrentFrame(); + const { fps } = useVideoConfig(); + const enter = spring({ frame: f, fps, config: { damping: 200 } }); + const out = interpolate(f, [dur - 10, dur], [1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + return ( + +
+
+ {text} +
+ {sub ? ( +
{sub}
+ ) : null} +
+
+ ); +}; + +// letterbox bars for the cinematic stretch +const Letterbox: React.FC = () => ( + <> +
+
+ +); + +// a full-screen title card +export const Card: React.FC<{ dur: number; children: React.ReactNode; bg?: string }> = ({ dur, children, bg = INK }) => { + const opacity = useEdgeFade(dur, 10); + return ( + + {children} + + ); +}; + +// the RetroRecomped logo (public/logo.png, from assets/logo/gen2logo.png), +// popping in on a spring +export const Logo: React.FC<{ width?: number }> = ({ width = 1300 }) => { + const f = useCurrentFrame(); + const { fps } = useVideoConfig(); + const pop = spring({ frame: f, fps, config: { damping: 12, stiffness: 120 } }); + return ( + + ); +}; + +// the "flat -> voxel" moment: a white flash right where the reveal clip switches +const Flash: React.FC<{ at: number }> = ({ at }) => { + const f = useCurrentFrame(); + const o = interpolate(f, [at - 2, at, at + 14], [0, 0.85, 0], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + easing: Easing.out(Easing.quad), + }); + return ; +}; + +// quick-cut montage: each entry is [clip, length in frames, start in the clip] +const Montage: React.FC<{ cuts: [string, number, number?][] }> = ({ cuts }) => { + let t = 0; + return ( + <> + {cuts.map(([src, len, from], i) => { + const start = t; + t += len; + return ( + + + + ); + })} + + ); +}; + +const GenTag: React.FC<{ gen: string; game: string; region: string; dur: number }> = ({ gen, game, region, dur }) => { + const f = useCurrentFrame(); + const { fps } = useVideoConfig(); + const enter = spring({ frame: f, fps, config: { damping: 200 } }); + const out = interpolate(f, [dur - 8, dur], [1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + return ( + +
+
{gen}
+
{game}
+
{region}
+
+
+ ); +}; + +// ------------------------------------------------------------------ the cut + +type Beat = { len: number; el: React.ReactNode }; + +const beats: Beat[] = [ + // 0:00 cold open + { + len: s(4), + el: ( + + +
+ THE GAMES YOU REMEMBER. REBUILT. +
+
+ ), + }, + // 0:04 the reveal: flat, then the town lifts into voxels + { + len: s(8), + el: ( + <> + + + + + + + + + + ), + }, + // 0:12 day and night + { + len: s(6), + el: ( + <> + + + + + + + + + ), + }, + // 0:18 Johto montage + { + len: s(6), + el: ( + <> + + + + ), + }, + // 0:24 first person + { + len: s(6), + el: ( + <> + + + + + ), + }, + // 0:30 third person + { + len: s(6), + el: ( + <> + + + + + ), + }, + // 0:36 battles on the map + { + len: s(8), + el: ( + <> + + + + ), + }, + // 0:44 diorama overview + { + len: s(4), + el: , + }, + // 0:48 Kanto + { + len: s(7), + el: ( + <> + + + + ), + }, + // 0:55 Hoenn + { + len: s(7), + el: ( + <> + + + + ), + }, + // 1:02 Sinnoh + { + len: s(8), + el: ( + <> + + + + ), + }, + // 1:10 coming soon + { len: s(6), el: }, + // 1:16 modding + { len: s(5), el: }, + // 1:21 end card + { len: s(9), el: }, +]; + +function ComingSoon({ dur }: { dur: number }) { + const f = useCurrentFrame(); + const { fps } = useVideoConfig(); + const games = [ + ["HEARTGOLD", "#e8b830"], + ["SOULSILVER", "#c8d0e0"], + ["BLACK", "#f0f0f0"], + ["WHITE", "#f0f0f0"], + ]; + return ( + +
COMING SOON
+
+ {games.map(([g, c], i) => { + const p = spring({ frame: f - 8 - i * 7, fps, config: { damping: 14 } }); + return ( +
+ {g} +
+ ); + })} +
+
Johto returns. Unova arrives.
+
+ ); +} + +function Modding({ dur }: { dur: number }) { + const f = useCurrentFrame(); + const items = ["Voxel dioramas", "3D battles", "First & third person", "Map packs", "Sprite sets", "Your own mods"]; + return ( + +
+ BUILT TO BE MODDED +
+
+ {items.map((t, i) => { + const o = interpolate(f, [8 + i * 5, 18 + i * 5], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + return ( +
+ + + {t} +
+ ); + })} +
+
+ ); +} + +function EndCard({ dur }: { dur: number }) { + const f = useCurrentFrame(); + const late = interpolate(f, [20, 40], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + return ( + + +
+
+ Red · Blue · Yellow · Gold · Silver · Crystal · FireRed · Emerald · Platinum +
+
+ Windows · macOS · Linux · Steam Deck · Android · Switch · iOS +
+
+ Rip packs with us on Tilt Rips: +
+
tiltrips.com/r/GEN2RECOMP
+
discord.gg/4VXnEnePT
+
+
+ ); +} + +export const TRAILER_FRAMES = beats.reduce((n, b) => n + b.len, 0); + +export const Trailer: React.FC = () => { + let t = 0; + return ( + + {beats.map((b, i) => { + const from = t; + t += b.len; + return ( + + {b.el} + + ); + })} + {MUSIC ? + ); +}; diff --git a/tools/trailer/remotion/src/index.ts b/tools/trailer/remotion/src/index.ts new file mode 100644 index 000000000..f31c790ed --- /dev/null +++ b/tools/trailer/remotion/src/index.ts @@ -0,0 +1,4 @@ +import { registerRoot } from "remotion"; +import { RemotionRoot } from "./Root"; + +registerRoot(RemotionRoot); diff --git a/tools/trailer/remotion/tsconfig.json b/tools/trailer/remotion/tsconfig.json new file mode 100644 index 000000000..453563375 --- /dev/null +++ b/tools/trailer/remotion/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/tools/translation/README.md b/tools/translation/README.md index 47783ef87..cf168b627 100644 --- a/tools/translation/README.md +++ b/tools/translation/README.md @@ -22,7 +22,7 @@ python -m venv tmp/translation-env .\tmp\translation-env\Scripts\python.exe tools/translation/build_desktop.py ``` -Include `translation/rom-translate.exe`, `data/localization`, and `src/translation` in desktop release packaging. Android releases use the Java/JNI bridge and do not need the Windows helper. Do not package downloaded model caches, test catalogs, or `tmp/`. +Include `translation/rom-translate.exe`, `data/localization`, and `src/translation` in desktop release packaging. The release workflow builds the helper per platform (PyInstaller cannot cross-compile): `windows-translator` on a Windows runner for the win64 zip and the MSIX, `linux-translator` in Debian bullseye for x86_64 (Linux AppImage) and arm64 (ARM64 AppImage, RG34XXSP port), and the macOS job in place. Each packaging job fails if its helper is missing from the archive. Android releases use the Java/JNI bridge and do not need the Windows helper. Do not package downloaded model caches, test catalogs, or `tmp/`. Tests: `tools/translation/check.lua` covers setting precedence, placeholders and read-only source overlays. `check_download.py` covers download progress and GPU fallback. `tools/translation/native_check` runs the real LOVE worker and packaged desktop helper. It downloads an English-Spanish model/font if missing, writes a separate test catalog, and renders the translated text. `launcher_check` translates the real launcher catalog, renders its buttons, and checks restoring English. Android Java compilation and NDK syntax checks cover its bridge; live Android inference still needs a device test.