diff --git a/docs/modding/reference/registries.md b/docs/modding/reference/registries.md index 4889c07e..cc01e163 100644 --- a/docs/modding/reference/registries.md +++ b/docs/modding/reference/registries.md @@ -9,6 +9,31 @@ different record, or no home at all -- the registry carries a Gen 2 subsection built from the same catalog entry. Concepts and verbs: [Concepts: Registries](Concepts-Registries). +## Reading this reference + +Use the wiki's `Choose-A-Registry` guide when you know what you want +to make but not the registry name. This page is the precise schema +catalog once you have chosen one. + +### Type notation + +- `string`, `number`, `boolean`, and `function` name the Lua value to + supply. A function is code the engine calls later; see the linked + concept/reference page for its arguments and return value. +- `{ field, otherField? }` is a record (a Lua table with named fields). + A `?` means that field is optional. `list of T` is an ordered Lua + table of values shaped like `T`; `map of K -> V` maps each key to a + value shaped like `V`. +- `A | B` means either shape is accepted. `moves id`, `items id`, and + similar phrases mean the internal id of a record in that registry, + not its displayed name. + +### Example status + +Every example below is **shape only**. Put an adapted call in your mod +entry file. Replace `...` and names such as `fn` with real values, and +supply every field marked required when registering a new record. + ## ai_classes - semantics: `record` @@ -21,7 +46,7 @@ subsection built from the same catalog entry. Concepts and verbs: | `choose` | function | no | | `hpBelow` | integer >= 1 | no | | `item` | items id | no | -| `kind` | one of "class" | "layer" | "brain" | no | +| `kind` | one of "class" \| "layer" \| "brain" | no | | `onStatus` | boolean | no | | `score` | function | no | | `switch` | boolean | no | @@ -29,6 +54,7 @@ subsection built from the same catalog entry. Concepts and verbs: | `switchChance` | integer 0..256 | no | | `uses` | integer >= 0 | no | + ```lua mod.content.ai_classes:patch("OPP_BROCK", { uses = 9 }) ``` @@ -54,6 +80,7 @@ and reported. See the Gold subsection below for where it does land. | `event` | integer >= 0 | yes | | `index` | integer >= 1 | yes | + ```lua mod.content.apricorns:override("RED_APRICORN", { apricorn = "RED_APRICORN", ball = "ULTRA_BALL", event = 600, index = 1 }) ``` @@ -70,6 +97,7 @@ mod.content.apricorns:override("RED_APRICORN", { apricorn = "RED_APRICORN", ball - **deprecated** -- use sfx / cries / map_songs / music - value: any value + ```lua mod.content.audio:override("mapSongs", { ... }) ``` @@ -89,6 +117,7 @@ mod.content.audio:override("mapSongs", { ... }) | `tossAnim` | string | no | | `wobbleFactor` | integer >= 1 | no | + ```lua mod.content.balls:override("GREAT_BALL", { randMax = 180, hpFactor = 12 }) ``` @@ -104,6 +133,7 @@ mod.content.balls:override("GREAT_BALL", { randMax = 180, hpFactor = 12 }) - target: `Data.battle_anims` - value: {seq, source?} | {blocks, type?} | {height, path, source?, tiles, width} + ```lua mod.content.battle_anims:register("SHADOW_BALL", { seq = { ... } }) ``` @@ -133,6 +163,7 @@ accepted and merged as-is. | `scripts` | map of string -> list of list of any value | | `source` | string | + ```lua mod.content.battle_anims:patch("moves", { SHADOW_BALL = "5e86" }) ``` @@ -147,6 +178,7 @@ mod.content.battle_anims:patch("moves", { SHADOW_BALL = "5e86" }) | `path` | file path | yes | | `scale` | number 0.25..4 | yes | + ```lua mod.content.battle_sprite_scales:register("abra_back", { path = "assets/generated/battle/back/abrab.png", @@ -169,6 +201,7 @@ scale composes with the send-out grow animation. - target: `Data.commands` - value: function | {blocking?, fn, foreground?} + ```lua mod.content.commands:register("shake_screen", function(ctx, frames) ... end) ``` @@ -197,6 +230,7 @@ accepted and merged as-is. | `moveMax` | integer >= 1 | | `partyMax` | integer >= 1 | + ```lua mod.content.constants:patch("levelCap", 80) ``` @@ -265,6 +299,7 @@ accepted and merged as-is. | `treeMonSetOrder` | list of string | | `types` | map of string -> integer >= 0 | + ```lua mod.content.constants:patch("speciesOrder", { [252] = "MODMON" }) ``` @@ -275,6 +310,7 @@ mod.content.constants:patch("speciesOrder", { [252] = "MODMON" }) - target: `Data.audio.cries` - value: {header, length, pitch} | {file} | {base, length?, pitch?} | {chip, length?, pitch?} + ```lua mod.content.cries:patch("PIKACHU", { pitch = 200 }) ``` @@ -296,6 +332,7 @@ and reported. See the Gold subsection below for where it does land. | `sprite` | integer 0..255 | yes | | `type` | integer 1..6 | yes | + ```lua mod.content.decorations:patch("deco:2", { name = "COZY" }) ``` @@ -316,6 +353,7 @@ mod.content.decorations:patch("deco:2", { name = "COZY" }) | `id` | string | no | | `water` | {rate, slots} | no | + ```lua mod.content.encounters:patch("ROUTE_1", { grass = { rate = 30 } }) ``` @@ -342,12 +380,13 @@ accepted and merged as-is. | `source` | string | | `swarmGrass` | map of string -> {map?, rates, slots} | | `swarmWater` | map of string -> {map?, rate, slots} | -| `timeFishGroups` | map of string | integer 0..255 -> {day, nite} | +| `timeFishGroups` | map of string \| integer 0..255 -> {day, nite} | | `treeMonsAsleep` | {DAY, MORN, NITE} | | `treeSets` | map of string -> {common, rare?} | | `trees` | map of string -> string | | `water` | map of string -> {map?, rate, slots} | + ```lua mod.content.encounters:patch("grass", { ROUTE_29 = { rates = { NITE = 40 } } }) ``` @@ -362,6 +401,7 @@ mod.content.encounters:patch("grass", { ROUTE_29 = { rates = { NITE = 40 } } }) | `check` | function | yes | | `describe` | function | no | + ```lua mod.content.evolution_methods:register("FRIENDSHIP", { check = fn }) ``` @@ -389,6 +429,7 @@ accepted and merged as-is. | `playerPics` | {back?, demoBack?, front?, oakBack?} | | `townMap` | {background?, cursorOrder?, gridPixelSize?, locations?, nest?, upArrow?} | + ```lua mod.content.field:patch("boot", { startMap = "SABLE_COVE" }) ``` @@ -406,6 +447,7 @@ to do for each one. - target: `Data.font` - value: {advance?, base, charmap?, glyphsPerRow?, image} | {code, seq} | {bold?, file?, size?, spacing?, tiles?, yOffset?} + ```lua mod.content.font:register("charmap:hiragana_a", { seq = "あ", code = 256 }) ``` @@ -419,6 +461,7 @@ mod.content.font:register("charmap:hiragana_a", { seq = "あ", code = 256 }) |---|---|---| | `expForLevel` | function | yes | + ```lua mod.content.growth_rates:register("ERRATIC", { expForLevel = fn }) ``` @@ -437,6 +480,7 @@ and reported. See the Gold subsection below for where it does land. | `heldEffect` | string | yes | | `heldParameter` | integer 0..255 | no | + ```lua mod.content.held_items:override("LEFTOVERS", { heldEffect = "HELD_LEFTOVERS", heldParameter = 0 }) ``` @@ -452,6 +496,7 @@ mod.content.held_items:override("LEFTOVERS", { heldEffect = "HELD_LEFTOVERS", he - target: `Data.icons.bySpecies` - value: string | {frames?, image} + ```lua mod.content.icons:register("MODMON", "QUADRUPED") -- a built-in name, or { image = mod.assets:path("icon.png"), frames = 2 } ``` @@ -465,6 +510,7 @@ The record differs; the registry name, the verbs and the id space do not. - value: string | {frames, height, id?, image, index?, width} + ```lua mod.content.icons:override("TOTODILE", "ICON_MONSTER") ``` @@ -481,6 +527,7 @@ mod.content.icons:override("TOTODILE", "ICON_MONSTER") | `needsTarget` | boolean | no | | `use` | function | yes | + ```lua mod.content.item_effects:register("MOON_FLUTE", { use = fn, field = true }) ``` @@ -507,6 +554,7 @@ mod.content.item_effects:register("MOON_FLUTE", { use = fn, field = true }) | `price` | integer >= 0 | yes | | `tossable` | boolean | no | + ```lua mod.content.items:patch("POTION", { price = 100 }) ``` @@ -548,6 +596,7 @@ and reported. See the Gold subsection below for where it does land. | `x` | integer >= 0 | yes | | `y` | integer >= 0 | yes | + ```lua mod.content.landmarks:patch("LANDMARK_ROUTE_29", { x = 12 }) ``` @@ -562,12 +611,17 @@ mod.content.landmarks:patch("LANDMARK_ROUTE_29", { x = 12 }) - semantics: `record` - target: `Data.link_fields` +**Cart protocol only:** this registry does not make a freely enabled +loose mod eligible for player link play. Player links are set up in the +launcher with a vanilla cart or a sealed custom cart. + | field | type | required | |---|---|---| | `pack` | function | no | -| `rev` | integer >= 0 | string | yes | +| `rev` | integer >= 0 \| string | yes | | `unpack` | function | no | + ```lua mod.content.link_fields:register("held_item", { rev = 1, pack = fn, unpack = fn }) ``` @@ -585,6 +639,7 @@ to do for each one. - target: `Data.map_scripts` - value: {onBoulderMoved?, onEnter?, onInteract?, onStep?, onVictory?, priority?, scripts?, snorlaxWake?, talk?} + ```lua mod.content.map_scripts:register("PALLET_TOWN", { talk = { ... } }) ``` @@ -602,6 +657,7 @@ to do for each one. - target: `Data.audio.mapSongs` - value: music id + ```lua mod.content.map_songs:override("PALLET_TOWN", "Music_Routes1") ``` @@ -615,7 +671,7 @@ mod.content.map_songs:override("PALLET_TOWN", "Music_Routes1") |---|---|---| | `blocks` | list of integer 0..255 | yes | | `borderBlock` | integer 0..255 | no | -| `connections` | map of one of "north" | "south" | "east" | "west" -> any value | no | +| `connections` | map of one of "north" \| "south" \| "east" \| "west" -> any value | no | | `height` | integer >= 1 | yes | | `id` | string | yes | | `index` | integer >= 0 | no | @@ -627,6 +683,7 @@ mod.content.map_songs:override("PALLET_TOWN", "Music_Routes1") | `warps` | list of {destGroup?, destMap, destMapNum?, destWarp, x, y} | no | | `width` | integer >= 1 | yes | + ```lua mod.content.maps:register("MY_CAVE", { tileset = "CAVERN", ... }) ``` @@ -642,6 +699,7 @@ mod.content.maps:register("MY_CAVE", { tileset = "CAVERN", ... }) - target: none - value: {run, since} + ```lua mod.content.migrations:register("my_mod", { since = "1.0.0", run = fn }) ``` @@ -654,9 +712,10 @@ mod.content.migrations:register("my_mod", { since = "1.0.0", run = fn }) | field | type | required | |---|---|---| | `accuracyChecked` | boolean | no | -| `kind` | one of "primary" | "secondary" | "full" | yes | +| `kind` | one of "primary" \| "secondary" \| "full" | yes | | `run` | function | no | + ```lua mod.content.move_effects:register("DRAIN_PP_EFFECT", { kind = "primary", run = fn }) ``` @@ -675,15 +734,15 @@ mod.content.move_effects:register("DRAIN_PP_EFFECT", { kind = "primary", run = f |---|---|---| | `accuracy` | integer 0..100 | yes | | `anim` | any value | no | -| `category` | one of "physical" | "special" | "status" | no | +| `category` | one of "physical" \| "special" \| "status" | no | | `chargeText` | string | no | | `counterable` | boolean | no | | `effect` | move_effects id | yes | -| `fixedDamage` | integer >= 1 | function | no | +| `fixedDamage` | integer >= 1 \| function | no | | `highCrit` | boolean | no | | `id` | string | yes | | `index` | integer 0..255 | no | -| `multiHit` | integer >= 1 | list of integer >= 1 | no | +| `multiHit` | integer >= 1 \| list of integer >= 1 | no | | `name` | string | yes | | `power` | integer 0..255 | yes | | `pp` | integer 0..64 | yes | @@ -691,6 +750,7 @@ mod.content.move_effects:register("DRAIN_PP_EFFECT", { kind = "primary", run = f | `semiInvulnerable` | boolean | no | | `type` | type_chart id | yes | + ```lua mod.content.moves:patch("BLIZZARD", { accuracy = 70 }) ``` @@ -707,15 +767,15 @@ do not. |---|---|---| | `accuracy` | integer 0..100 | yes | | `anim` | any value | no | -| `category` | one of "physical" | "special" | "status" | no | +| `category` | one of "physical" \| "special" \| "status" | no | | `chargeText` | string | no | | `counterable` | boolean | no | | `effect` | move_effects id | yes | -| `fixedDamage` | integer >= 1 | function | no | +| `fixedDamage` | integer >= 1 \| function | no | | `highCrit` | boolean | no | | `id` | string | yes | | `index` | integer 0..255 | no | -| `multiHit` | integer >= 1 | list of integer >= 1 | no | +| `multiHit` | integer >= 1 \| list of integer >= 1 | no | | `name` | string | yes | | `power` | integer 0..255 | yes | | `pp` | integer 0..64 | yes | @@ -729,6 +789,7 @@ do not. - target: `Data.audio.songs` - value: {address, bank, engine?} | {file, intro?, loopFile?, loopSeconds?, seconds?} | {channels, drums?, program, waves?} | {chip} + ```lua mod.content.music:register("MOD_SONG", { file = "song.ogg" }) ``` @@ -739,6 +800,7 @@ mod.content.music:register("MOD_SONG", { file = "song.ogg" }) - target: `Data.palettes.palettes` - value: list of list of integer 0..255 | {colors} + ```lua mod.content.palettes:override("MEWMON", { {255,255,255}, ... }) ``` @@ -759,11 +821,11 @@ accepted and merged as-is. | `battleObjects` | map of string -> list of list of integer 0..255 | | `bg` | list of list of list of integer 0..255 | | `daytimes` | list of string | -| `environments` | map of string -> map of one of "MORN" | "DAY" | "NITE" | "DARK" -> list of integer >= 0 | +| `environments` | map of string -> map of one of "MORN" \| "DAY" \| "NITE" \| "DARK" -> list of integer >= 0 | | `expBar` | list of list of integer 0..255 | | `generation` | integer >= 1 | -| `hpBar` | map of one of "green" | "yellow" | "red" | "blue" -> list of list of integer 0..255 | -| `objects` | map of one of "MORN" | "DAY" | "NITE" | "DARK" -> list of list of list of integer 0..255 | +| `hpBar` | map of one of "green" \| "yellow" \| "red" \| "blue" -> list of list of integer 0..255 | +| `objects` | map of one of "MORN" \| "DAY" \| "NITE" \| "DARK" -> list of list of list of integer 0..255 | | `partyMenu` | list of list of list of integer 0..255 | | `pokemon` | map of string -> {normal, shiny} | | `roofSlot` | integer 0..7 | @@ -772,6 +834,7 @@ accepted and merged as-is. | `source` | string | | `trainers` | map of string -> list of list of integer 0..255 | + ```lua mod.content.palettes:patch("pokemon", { TOTODILE = { shiny = { {255,255,255}, {255,0,0} } } }) ``` @@ -800,6 +863,7 @@ and reported. See the Gold subsection below for where it does land. | `name` | string | no | | `number` | integer 0..255 | no | + ```lua mod.content.phone_contacts:patch("PHONE_YOUNGSTER_JOEY", { map = "ROUTE_31" }) ``` @@ -828,7 +892,7 @@ mod.content.phone_contacts:patch("PHONE_YOUNGSTER_JOEY", { map = "ROUTE_31" }) | `evolutions` | list of {item?, level?, method, species} | yes | | `frontSize` | integer 1..7 | yes | | `growthRate` | growth_rates id | yes | -| `icon` | string | {frames?, image} | no | +| `icon` | string \| {frames?, image} | no | | `id` | string | yes | | `index` | integer 0..255 | no | | `learnset` | list of {level, move} | yes | @@ -841,6 +905,7 @@ mod.content.phone_contacts:patch("PHONE_YOUNGSTER_JOEY", { map = "ROUTE_31" }) | `trueColor` | boolean | no | | `types` | list of type_chart id | yes | + ```lua mod.content.pokemon:patch("MEW", { baseStats = { attack = 120 } }) ``` @@ -885,6 +950,7 @@ do not. | `trueColor` | boolean | no | | `types` | list of type_chart id | yes | + ```lua mod.content.pokemon:patch("TOTODILE", { baseStats = { specialAttack = 80 } }) ``` @@ -903,6 +969,7 @@ and reported. See the Gold subsection below for where it does land. | `channel` | integer 0..255 | no | | `name` | string | no | + ```lua mod.content.radio_channels:register("PIRATE_RADIO", { channel = 9, name = "PIRATE RADIO" }) ``` @@ -931,6 +998,7 @@ mod.content.radio_channels:register("PIRATE_RADIO", { channel = 9, name = "PIRAT | `update` | function | no | | `worldPresent` | function | no | + ```lua mod.content.render_pipelines:register("voxel", { label = "VOXEL", levels = { "OFF", "15", "35", "50" }, drawWorld = fn }) ``` @@ -945,6 +1013,7 @@ Gen 1 table to merge into and a write here on a Gen 1 boot is dropped and reported. See the Gold subsection below for where it does land. - value: string + ```lua mod.content.rom_text:override("_WokeUpText", "%s se réveille !") ``` @@ -963,6 +1032,7 @@ mod.content.rom_text:override("_WokeUpText", "%s se réveille !") |---|---|---| | `name` | string | yes | + ```lua mod.content.rulesets:register("no_crits", { name = "no crits", critRate = 0 }) ``` @@ -980,6 +1050,7 @@ to do for each one. - target: `Data.screens` - value: function | {new} + ```lua mod.content.screens:register("QuestLog", { new = function(game) ... end }) ``` @@ -990,6 +1061,7 @@ mod.content.screens:register("QuestLog", { new = function(game) ... end }) - target: `Data.audio.sfx` - value: string | {address, bank, engine?} | {file} | {chip} + ```lua mod.content.sfx:register("SFX_MOD_CHIME", { file = "chime.ogg" }) ``` @@ -1012,6 +1084,7 @@ mod.content.sfx:register("SFX_MOD_CHIME", { file = "chime.ogg" }) | `trueColor` | boolean | no | | `walker` | boolean | no | + ```lua mod.content.sprites:register("SPRITE_HERO", { image = "...", frames = 6 }) ``` @@ -1035,10 +1108,11 @@ do not. | `paletteSource` | string | no | | `source` | string | no | | `species` | pokemon id | no | -| `spriteType` | one of "WALKING_SPRITE" | "STANDING_SPRITE" | "STILL_SPRITE" | "POKEMON_SPRITE" | no | +| `spriteType` | one of "WALKING_SPRITE" \| "STANDING_SPRITE" \| "STILL_SPRITE" \| "POKEMON_SPRITE" | no | | `trueColor` | boolean | no | | `walker` | boolean | no | + ```lua mod.content.sprites:patch("SPRITE_BEAUTY", { palette = "PAL_OW_RED", paletteId = 0 }) ``` @@ -1063,6 +1137,7 @@ mod.content.sprites:patch("SPRITE_BEAUTY", { palette = "PAL_OW_RED", paletteId = | `shakeBonus` | integer 0..255 | no | | `statPenalty` | {div, stat} | no | + ```lua mod.content.statuses:patch("BRN", { catchBonus = 12 }) ``` @@ -1078,6 +1153,7 @@ mod.content.statuses:patch("BRN", { catchBonus = 12 }) - target: `Data.strings` - value: string + ```lua mod.content.strings:override("But, it failed!", "Echec !") ``` @@ -1088,6 +1164,7 @@ mod.content.strings:override("But, it failed!", "Echec !") - target: `Data.text` - value: string + ```lua mod.content.text:override("_PalletTownText1", "HELLO!") ``` @@ -1107,6 +1184,7 @@ shape. - value: map of string -> {asm?, cableClub?, label?, mart?, nurse?, pc?, text?} + ```lua mod.content.text_pointers:patch("PalletTown", { TEXT_PALLETTOWN_SIGN = { text = "_MySign" } }) ``` @@ -1138,6 +1216,7 @@ to do for each one. | `walkable` | any value | no | | `warpTiles` | any value | no | + ```lua mod.content.tilesets:register("MY_TILES", { image = "...", blocks = { ... } }) ``` @@ -1153,6 +1232,7 @@ mod.content.tilesets:register("MY_TILES", { image = "...", blocks = { ... } }) - target: `Data.tokens` - value: function + ```lua mod.content.tokens:register("CLOCK", function(game) return "12" end) ``` @@ -1180,6 +1260,7 @@ mod.content.tokens:register("CLOCK", function(game) return "12" end) | `pic` | file path | no | | `trueColor` | boolean | no | + ```lua mod.content.trainers:patch("OPP_BROCK", { baseMoney = 99 }) ``` @@ -1206,6 +1287,7 @@ do not. | `trainers` | list of {id?, index?, name, party, trainerType?} | yes | | `trueColor` | boolean | no | + ```lua mod.content.trainers:patch("BEAUTY", { baseMoney = 99 }) ``` @@ -1222,6 +1304,7 @@ mod.content.trainers:patch("BEAUTY", { baseMoney = 99 }) | `frames` | integer >= 1 | yes | | `sound` | string | no | + ```lua mod.content.transitions:register("dissolve", { frames = 30, draw = fn }) ``` @@ -1239,6 +1322,7 @@ to do for each one. - target: `Data.type_chart` - value: {multiplier} | {category, index?, name?} + ```lua mod.content.type_chart:register("BUG>PSYCHIC_TYPE", { multiplier = 20 }) ``` diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 4b96961a..400b8b10 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -2002,24 +2002,33 @@ function Game2:hotkey(key) return self:pipelineHotkey(key, options, persist) end if key == "-" or key == "kp-" then - self.world:zoomStep(-1) - options.zoom = require("src.render.Zoom").offset - persist() + self:zoomStep(-1) return true elseif key == "=" or key == "kp+" then - self.world:zoomStep(1) - options.zoom = require("src.render.Zoom").offset - persist() + self:zoomStep(1) return true elseif key == "4" then self.world:zoomCycle() - options.zoom = require("src.render.Zoom").offset - persist() + self:storeZoom() return true end return self:pipelineHotkey(key, options, persist) end +function Game2:storeZoom() + local options = self.options or {} + self.options = options + options.zoom = require("src.render.Zoom").offset + if self.save then self.save.options = options end + self:persistOptions() +end + +function Game2:zoomStep(delta) + if not (self.world and self.world.map) then return end + self.world:zoomStep(delta) + self:storeZoom() +end + -- The (top, overworld) pair src/render/Pipelines.lua's free-roam gate reads. -- -- Gen 1 hands it (stack:top(), overworld), and the default gate (Zoom.gateOK) @@ -2086,11 +2095,10 @@ end function Game2:wheelmoved(_x, dy) local function vanilla() if self.phase == "boot" or self.stack:top() then return end - if not (self.world and self.world.map) then return end if dy > 0 then - self.world:zoomStep(1) + self:zoomStep(1) elseif dy < 0 then - self.world:zoomStep(-1) + self:zoomStep(-1) end end if not ModRuntime.wantsHook("input.wheel") then return vanilla() end diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 8232a64e..0be076f5 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -1703,7 +1703,6 @@ local function buildHeader(imp, m) local o = chrome.sync o.active = imp._syncModal ~= nil btn(imp, tx, ty, w, tabH, "tab-sync", "", o) - overlayBeta(tx, ty, w, tabH, m) local eng = imp._sync if eng and eng.busy and eng:busy() then Kit.spinner(tx + w - math.floor(8 * m.s), ty + math.floor(8 * m.s), @@ -5619,15 +5618,11 @@ local function buildDepResolverModal(imp, m) end end -local SYNC_HINT = "Save sync keeps your saves and your mod list on our server so another device can pick them up. It is brand new, so keep your own backups too." +local SYNC_HINT = "Save sync keeps your saves and your mod list on our server so another device can pick them up. Keep your own backups too." local function syncTitle(imp, m, px, py, pw, pad) local label = Strings("SAVE SYNC") Kit.text("button", label, px + pad, py, PAL.heading) - local bh = math.floor(15 * m.s) - local bw = Kit.textWidth("micro", "BETA") + math.floor(14 * m.s) - drawBetaTag(px + pad + Kit.textWidth("button", label) + math.floor(8 * m.s), - py + (Kit.textHeight("button") - bh) / 2, bw, bh) return py + Kit.textHeight("button") + math.floor(12 * m.s) end @@ -5901,7 +5896,7 @@ local function buildSyncHome(imp, m, eng) local pad = math.floor(18 * m.s) local w = syncWidth(m, math.floor(460 * m.s)) local linked = eng:linked() - local codes = eng.codes + local codes = linked and eng.codes or nil local body = linked and Strings("This device is linked. Saves sync when the launcher opens, a few seconds after each save, and every few minutes while the app is running.") or Strings(SYNC_HINT) @@ -5918,7 +5913,7 @@ local function buildSyncHome(imp, m, eng) fit = syncFit(m, 2 * pad + Kit.textHeight("button") + math.floor(22 * m.s) + codesH + devicesH + syncReserve(m, eng), - (linked and 4 or 3) + #devices, (linked and 4 or 3) + #devices, + (linked and 5 or 3) + #devices, (linked and 5 or 3) + #devices, { { font = "small", str = body, w = innerW, max = 5 } }) if fit.over <= 0 or #devices == 0 then break end table.remove(devices) @@ -5930,7 +5925,7 @@ local function buildSyncHome(imp, m, eng) fit.lines[1]) + math.floor(10 * m.s) if codes then - Kit.text("small", Strings("Enter these on your other device:"), px + pad, + Kit.text("small", Strings("Your sync codes, enter these on another device:"), px + pad, cy, PAL.muted) cy = cy + Kit.textHeight("small") + math.floor(6 * m.s) Kit.text("title", codes.code1, px + pad, cy, PAL.heading) @@ -5967,6 +5962,10 @@ local function buildSyncHome(imp, m, eng) cy = syncRow(imp, m, px + pad, cy, innerW, "sync-mods", Strings("Share or get a mod list"), { kind = "accent", action = function() imp:_syncView("mods") end }, fit) + cy = syncRow(imp, m, px + pad, cy, innerW, "sync-codes", + codes and Strings("Get new sync codes") or Strings("Show my sync codes"), + { enabled = not eng:busy(), + action = function() imp:_syncCodes() end }, fit) cy = syncRow(imp, m, px + pad, cy, innerW, "sync-unlink", Strings("Unlink this device"), { kind = "danger", action = function() imp:_syncUnlink() end }, fit) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 23ad8e55..ab210735 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -4340,6 +4340,12 @@ function RomImporter:_syncNow() return eng:syncNow() end +function RomImporter:_syncCodes() + local eng = self:_syncEngine() + if not eng then return false end + return eng:reissueCodes() +end + function RomImporter:_syncUnlink() local eng = self:_syncEngine() if not eng then return false end diff --git a/src/mods/Gen2Compat.lua b/src/mods/Gen2Compat.lua index 0db6629a..03d3a810 100644 --- a/src/mods/Gen2Compat.lua +++ b/src/mods/Gen2Compat.lua @@ -281,11 +281,7 @@ local function buildGame() function translate.zoomStep() return function(delta) local g = live() - if not (g and g.world and g.world.map) then return end - g.world:zoomStep(delta) - g.options = g.options or {} - g.options.zoom = rawRequire("src.render.Zoom").offset - if g.persistOptions then g:persistOptions() end + if g and g.zoomStep then g:zoomStep(delta) end end end diff --git a/src/render/ShaderFX.lua b/src/render/ShaderFX.lua index 5a83ed2d..2be23f2d 100644 --- a/src/render/ShaderFX.lua +++ b/src/render/ShaderFX.lua @@ -1156,6 +1156,8 @@ function ShaderFX.render(canvas, rect, source, dpiX, dpiY, opts) end local chainOut, chainPreset + local clipX, clipY, clipW, clipH = love.graphics.getScissor() + love.graphics.setScissor() local okCrop, frameSource = pcall(cropToGbSource, canvas, rect, source.w, source.h, scale, layer) if okCrop then lastCropError = nil @@ -1186,6 +1188,7 @@ function ShaderFX.render(canvas, rect, source, dpiX, dpiY, opts) end end + if clipX then love.graphics.setScissor(clipX, clipY, clipW, clipH) end love.graphics.setColor(1, 1, 1, 1) if masked then if not chainOut then diff --git a/src/sync/SyncClient.lua b/src/sync/SyncClient.lua index 6883a625..09d5eb4f 100644 --- a/src/sync/SyncClient.lua +++ b/src/sync/SyncClient.lua @@ -209,6 +209,10 @@ function SyncClient:setDisplayName(name) return self:send("POST", "/sync/displayname", { displayName = name }) end +function SyncClient:reissueCodes() + return self:send("POST", "/sync/codes") +end + function SyncClient:unlink(device) return self:send("POST", "/sync/unlink", { device = device }) end diff --git a/src/sync/SyncEngine.lua b/src/sync/SyncEngine.lua index 6cce8d9e..c18e841e 100644 --- a/src/sync/SyncEngine.lua +++ b/src/sync/SyncEngine.lua @@ -299,7 +299,7 @@ function SyncEngine.new(opts) eng.phase = "idle" eng.error = nil eng.conflicts = {} - eng.codes = nil + eng.codes = SyncEngine.formatCodes(eng.state) eng.modPlan = nil eng.shareCode = nil eng.clock = 0 @@ -450,6 +450,14 @@ function SyncEngine:update(dt) end end +function SyncEngine.formatCodes(state) + if type(state) ~= "table" then return nil end + local a = SyncClient.formatCode(state.code1) + local b = SyncClient.formatCode(state.code2) + if not a or not b then return nil end + return { code1 = a, code2 = b } +end + function SyncEngine:createAccount(label) if self:busy() then return false, "sync is busy" end self.phase = "checking" @@ -462,9 +470,11 @@ function SyncEngine:createAccount(label) eng:_fail("the server sent an unexpected reply") return end - eng.codes = { - code1 = SyncClient.formatCode(data.code1) or tostring(data.code1 or ""), - code2 = SyncClient.formatCode(data.code2) or tostring(data.code2 or ""), + eng.state.code1 = SyncClient.normalizeCode(data.code1) + eng.state.code2 = SyncClient.normalizeCode(data.code2) + eng.codes = SyncEngine.formatCodes(eng.state) or { + code1 = tostring(data.code1 or ""), + code2 = tostring(data.code2 or ""), } eng.state.account = data.account eng.state.deviceToken = data.deviceToken @@ -502,6 +512,8 @@ function SyncEngine:linkDevice(code1, code2, label) eng.state.deviceId = type(data.device) == "string" and data.device or nil eng.state.deviceLabel = label eng.state.enabled = true + eng.state.code1, eng.state.code2 = a, b + eng.codes = SyncEngine.formatCodes(eng.state) eng.client:setAuth(data.account, data.deviceToken) eng.status = "This device is linked" eng:_persist() @@ -560,6 +572,29 @@ function SyncEngine:unlinkDevice(deviceId) end) end +function SyncEngine:reissueCodes() + if not self:linked() then return false, "this device is not linked" end + if self:busy() then return false, "sync is busy" end + self.phase = "checking" + self.status = "Fetching new sync codes..." + self.error = nil + local handle, err = self.client:reissueCodes() + return self:_request(handle, err, function(eng, res) + local data = res.data or {} + local a = SyncClient.normalizeCode(data.code1) + local b = SyncClient.normalizeCode(data.code2) + if not a or not b then + eng:_fail("the server sent an unexpected reply") + return + end + eng.state.code1, eng.state.code2 = a, b + eng.codes = SyncEngine.formatCodes(eng.state) + eng.phase = "idle" + eng.status = "New sync codes issued, the old pair no longer links" + eng:_persist() + end) +end + function SyncEngine:setEnabled(enabled) self.state.enabled = enabled and true or false self:_persist() diff --git a/src/sync/SyncState.lua b/src/sync/SyncState.lua index 8040f8bc..b0c930c0 100644 --- a/src/sync/SyncState.lua +++ b/src/sync/SyncState.lua @@ -19,6 +19,13 @@ local function str(v) return nil end +local function code(v) + if type(v) ~= "string" and type(v) ~= "number" then return nil end + local digits = tostring(v):gsub("[^%d]", "") + if #digits ~= 8 then return nil end + return digits +end + local function num(v) local n = tonumber(v) if type(n) ~= "number" or n ~= n or n == math.huge or n == -math.huge then @@ -36,6 +43,8 @@ function SyncState.sanitize(raw) out.deviceId = str(raw.deviceId) out.deviceLabel = str(raw.deviceLabel) out.displayName = str(raw.displayName) + out.code1 = code(raw.code1) + out.code2 = code(raw.code2) out.lastSyncAt = num(raw.lastSyncAt) or 0 if type(raw.revs) == "table" then for key, rev in pairs(raw.revs) do diff --git a/tests/drivers/skin_shaderfx_cutout_test.lua b/tests/drivers/skin_shaderfx_cutout_test.lua new file mode 100644 index 00000000..3980186f --- /dev/null +++ b/tests/drivers/skin_shaderfx_cutout_test.lua @@ -0,0 +1,87 @@ +-- A touch skin's screen cutout with a SHADER FX preset on, both generations. +-- +-- POKEPORT_TOUCH=1 POKEPORT_SKIN=gb_anim POKEPORT_VERSION=red POKEPORT_IDENTITY= \ +-- POKEPORT_SHOT_DIR= POKEPORT_DRIVER=tests/drivers/skin_shaderfx_cutout_test.lua love . +-- +-- The identity needs the game's cache and a shaders/ library (copy both from +-- pokemon-love2d). Never run under POKEPORT_SPEED: the shots are live frames. +local U = require("tests.drivers.util") +local ShaderFX = require("src.render.ShaderFX") +local TouchControls = require("src.core.TouchControls") +local Playfield = require("src.render.Playfield") +local GameVersion = require("src.core.GameVersion") + +local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots" + +return function(game) + local fails = 0 + local function say(line) print("[skinfx] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "PASS " or "FAIL ") .. line) + end + + local function litRows(path) + local f = io.open(path, "rb") + if not f then return nil end + local bytes = f:read("*a") + f:close() + local image = love.image.newImageData(love.filesystem.newFileData(bytes, "shot.png")) + local w, h = love.graphics.getDimensions() + local cx, cy, cw, ch = Playfield.cutout(w, h) + if not cx then return nil end + local sx = image:getWidth() / w + local lit = {} + for _, frac in ipairs({ 0.1, 0.5, 0.9 }) do + local y = math.floor((cy + ch * frac) * sx) + local n = 0 + for x = cx, cx + cw - 1, 4 do + local r, g, b = image:getPixel(math.floor(x * sx), y) + if r > 0.05 or g > 0.05 or b > 0.05 then n = n + 1 end + end + lit[#lit + 1] = n + end + return lit + end + + love.window.setMode(460, 950) + local want = os.getenv("POKEPORT_SKIN") or "gb_anim" + local skin, err = (game.touchControls or TouchControls):selectSkin(want) + ok(skin ~= nil, "selected skin " .. want .. " " .. tostring(err or "")) + local entry + for _, e in ipairs(ShaderFX.list()) do + if e.converted or ShaderFX.convert(e) then entry = e break end + end + ok(entry ~= nil, "a shader preset is available to activate") + if not (skin and entry) then love.event.quit(1) return end + + U.wait(60) + if GameVersion.generation() == 2 then + game.world:warpToMapId("NEW_BARK_TOWN", 6, 6, "down") + else + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + end + U.wait(60) + local tag = tostring(GameVersion.get()) + U.shot(game, SHOT_DIR .. "/skinfx_" .. tag .. "_01_cutout_plain.png") + + local on, aerr = ShaderFX.activate("main", entry) + ok(on, "activated " .. tostring(entry.name or entry.id) .. " " .. tostring(aerr or "")) + U.wait(30) + local shot = SHOT_DIR .. "/skinfx_" .. tag .. "_02_cutout_shader.png" + U.shot(game, shot) + local lit = litRows(shot) + ok(lit ~= nil, "the shot reads back with a live cutout") + if lit then + ok(lit[1] > 20 and lit[2] > 20 and lit[3] > 20, + ("the shaded picture fills the cutout top to bottom (%d/%d/%d lit samples)") + :format(lit[1], lit[2], lit[3])) + end + ShaderFX.deactivate("main") + + say(fails == 0 and "all claims passed" or (fails .. " claims failed")) + say("02_cutout_shader should be the same town as 01, shaded, inside the") + say("skin's screen window. a black top half with a strip of town at the") + say("bottom is the bug.") + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/engine/gen2_wheel_zoom_persists_bug2166.lua b/tests/engine/gen2_wheel_zoom_persists_bug2166.lua new file mode 100644 index 00000000..564764f8 --- /dev/null +++ b/tests/engine/gen2_wheel_zoom_persists_bug2166.lua @@ -0,0 +1,73 @@ +-- #2166: the mouse wheel zoomed Gen 2's world through World:zoomStep without +-- writing the new offset into options.zoom, so the next Game2:applyOptions +-- (leaving OPTIONS, CONTINUE) snapped the camera back to the stored level. +-- luajit tests/engine/gen2_wheel_zoom_persists_bug2166.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local Zoom = require("src.render.Zoom") +local Game2 = require("src.core.Game2") + +local function fakeGame() + local persisted = 0 + local world = { map = {}, fitScale = function() return 4 end } + function world:zoomStep(delta) Zoom.step(delta, self:fitScale()) end + function world:zoomCycle() Zoom.cycle(self:fitScale()) end + local game = { + world = world, + stack = { top = function() return nil end }, + options = { zoom = 0 }, + save = { options = nil }, + persistOptions = function() persisted = persisted + 1 end, + } + setmetatable(game, { __index = Game2 }) + return game, function() return persisted end +end + +do + Zoom.reset() + local game, persisted = fakeGame() + Game2.wheelmoved(game, 0, -1) + eq(Zoom.offset, -1, "wheel down zooms the live world one step out") + eq(game.options.zoom, -1, "BUG FIX: wheel zoom is written into options.zoom") + eq(game.save.options, game.options, "the save's options table follows") + eq(persisted(), 1, "and persisted once") + + Game2.applyOptions(game) + eq(Zoom.offset, -1, "BUG FIX: re-applying options keeps the wheel zoom") + + Game2.wheelmoved(game, 0, 1) + Game2.wheelmoved(game, 0, 1) + eq(game.options.zoom, 1, "wheel up steps back in and is stored each time") + eq(persisted(), 3, "one persist per wheel step") +end + +do + Zoom.reset() + local game, persisted = fakeGame() + Game2.hotkey(game, "-") + eq(game.options.zoom, -1, "the - hotkey still stores the offset") + Game2.hotkey(game, "4") + eq(game.options.zoom, Zoom.offset, "the 4 hotkey still stores the offset") + eq(persisted(), 2, "hotkeys persist once each") +end + +do + Zoom.reset() + local game = fakeGame() + game.world = nil + Game2.wheelmoved(game, 0, 1) + eq(game.options.zoom, 0, "no world: the wheel is ignored") + game = fakeGame() + game.stack.top = function() return {} end + Game2.wheelmoved(game, 0, 1) + eq(game.options.zoom, 0, "a menu on the stack: the wheel is ignored") + check(Zoom.offset == 0, "and the live offset is untouched") +end + +Zoom.reset() +T.finish("gen2 wheel zoom persists (#2166)") diff --git a/tests/engine/launcher_sync_modal.lua b/tests/engine/launcher_sync_modal.lua index 1e9382ac..6e298cbe 100644 --- a/tests/engine/launcher_sync_modal.lua +++ b/tests/engine/launcher_sync_modal.lua @@ -385,8 +385,12 @@ check(header and header:find('id = "skins"', 1, true) ~= nil, "and it sits beside the skins tab") check(header and header:find("beta = true", 1, true) ~= nil, "the skins tab carries a BETA badge too") -check(view:find('"BETA"', 1, true) ~= nil, - "the button and the modal are labelled BETA") +local syncTab = view:match('btn%(imp, tx, ty, w, tabH, "tab%-sync", "", o%)\n(.-)\n') +check(syncTab and not syncTab:find("overlayBeta", 1, true), + "the Save Sync button carries no BETA badge") +local title = view:match("local function syncTitle%(.-\nend") +check(title and not title:find("Beta", 1, true), + "nor does the Save Sync popup title") check(view:find("buildSyncModal", 1, true) ~= nil, "the sync UI is a modal, so it works from any tab") local modals = view:match("local function modalUp%(imp%)(.-)\nend") diff --git a/tests/engine/sync_engine_test.lua b/tests/engine/sync_engine_test.lua index c9df5db3..b5ff576d 100644 --- a/tests/engine/sync_engine_test.lua +++ b/tests/engine/sync_engine_test.lua @@ -107,7 +107,10 @@ do T.eq(eng.state.account, "aa11", "and stores the account id") T.eq(eng.codes.code1, "1111-2222", "the first code is shown grouped") T.eq(eng.codes.code2, "3333-4444", "and so is the second") - T.eq(eng.state.code1, nil, "codes never enter the persisted state") + T.eq(eng.state.code1, "11112222", "the codes ride the persisted state") + T.eq(eng.state.code2, "33334444", "both of them") + T.eq(SyncEngine.formatCodes(SyncState.sanitize(eng.state)).code1, "1111-2222", + "so a relaunch can show them again") T.eq(transport.sent[2].url, "http://sync.test/sync/state", "creating the account starts a sync straight away") T.eq(transport.sent[3].method, "PUT", @@ -127,6 +130,8 @@ do eng:linkDevice("1111-2222", "3333 4444", "phone") pump(eng) T.eq(eng:linked(), true, "linking with both codes links the device") + T.eq(eng.codes.code1, "1111-2222", "the codes typed to link are kept too") + T.eq(eng.state.code2, "33334444", "as bare digits in the state") T.eq(transport.sent[2].url, "http://sync.test/sync/state", "and a sync starts immediately") T.eq(transport.sent[3].method, "PUT", @@ -509,6 +514,26 @@ do "and it survives being persisted") end +do + local linked = SyncState.defaults() + linked.account, linked.deviceToken, linked.enabled = "aa11", "tok", true + local eng, transport = engine({ + ["POST /sync/codes"] = { code = 200, + body = '{"account":"aa11","code1":"55556666","code2":"77778888"}' }, + }, {}, linked) + T.eq(eng.codes, nil, "a device linked before codes were kept has none to show") + eng:reissueCodes() + pump(eng) + T.eq(transport.sent[1].url, "http://sync.test/sync/codes", + "asking for codes posts to the codes endpoint") + T.eq(eng.codes.code1, "5555-6666", "and the reissued pair is shown grouped") + T.eq(eng.state.code2, "77778888", "and kept in the state") + T.eq(eng.phase, "idle", "the engine settles") + + local unlinked = engine({}, {}, SyncState.defaults()) + T.eq(unlinked:reissueCodes(), false, "an unlinked device cannot ask for codes") +end + do local eng = engine({ ["POST /sync/link"] = { code = 200, diff --git a/tests/engine/sync_state_test.lua b/tests/engine/sync_state_test.lua index 066aa734..518b3b9a 100644 --- a/tests/engine/sync_state_test.lua +++ b/tests/engine/sync_state_test.lua @@ -58,8 +58,12 @@ do T.eq(dirty.account, "aa11bb22cc33dd44", "the account id survives") T.eq(dirty.deviceLabel, nil, "an empty device label is dropped") T.eq(dirty.lastSyncAt, 0, "a NaN lastSyncAt is refused") - T.eq(dirty.code1, nil, "the first account code is never kept") - T.eq(dirty.code2, nil, "nor the second") + T.eq(dirty.code1, "12345678", "the first account code is kept") + T.eq(dirty.code2, "87654321", "and the second") + T.eq(SyncState.sanitize({ code1 = "1234", code2 = "8765-4321" }).code1, nil, + "a code that is not 8 digits is dropped") + T.eq(SyncState.sanitize({ code1 = "1234", code2 = "8765-4321" }).code2, + "87654321", "and a grouped code is stored as bare digits") T.eq(dirty.revs["red/aaa"], 4, "numeric revs survive") T.eq(dirty.revs["red/bad"], nil, "a non-numeric rev is dropped") T.eq(dirty.revs[7], nil, "a non-string rev key is dropped") @@ -83,8 +87,8 @@ do SyncState.save(state) T.check(files["options.lua"] ~= nil, "the state lands in options.lua") - T.eq(files["options.lua"]:find("12345678", 1, true), nil, - "the account codes are never written to disk") + T.check(files["options.lua"]:find("12345678", 1, true) ~= nil, + "the account codes are written with the state so the popup can show them") local back = SyncState.load() T.eq(SyncState.linked(back), true, "the linked account survives a reload") @@ -93,7 +97,7 @@ do "and the device id the server revokes tokens by") T.eq(SyncState.rev(back, "red/abc"), 3, "and the last synced rev") T.eq(SyncState.stamp(back, "red/abc"), 1700000000, "and the savedAt stamp") - T.eq(back.code1, nil, "the code is gone from the reloaded state") + T.eq(back.code1, "12345678", "the code survives the reload") local opts = SaveData.loadOptions() T.eq(opts.textSpeed, 3, "writing sync state leaves other options alone") diff --git a/tools/gen_registry_docs.lua b/tools/gen_registry_docs.lua index cb2397d0..6c5a2e34 100644 --- a/tools/gen_registry_docs.lua +++ b/tools/gen_registry_docs.lua @@ -49,6 +49,41 @@ line("writes, and the value schema. Where Gold differs -- a different table, a") line("different record, or no home at all -- the registry carries a Gen 2") line("subsection built from the same catalog entry. Concepts and verbs:") line("[Concepts: Registries](Concepts-Registries).") +line("") +line("## Reading this reference") +line("") +if explicit then + line("Start with [Choose a Registry](Choose-A-Registry) when you know what") + line("you want to make but not the registry name. This page is the precise") + line("schema catalog once you have chosen one.") +else + line("Use the wiki's `Choose-A-Registry` guide when you know what you want") + line("to make but not the registry name. This page is the precise schema") + line("catalog once you have chosen one.") +end +line("") +line("### Type notation") +line("") +line("- `string`, `number`, `boolean`, and `function` name the Lua value to") +line(" supply. A function is code the engine calls later; see the linked") +line(" concept/reference page for its arguments and return value.") +line("- `{ field, otherField? }` is a record (a Lua table with named fields).") +line(" A `?` means that field is optional. `list of T` is an ordered Lua") +line(" table of values shaped like `T`; `map of K -> V` maps each key to a") +line(" value shaped like `V`.") +line("- `A | B` means either shape is accepted. `moves id`, `items id`, and") +line(" similar phrases mean the internal id of a record in that registry,") +line(" not its displayed name.") +line("") +line("### Example status") +line("") +line("Every example below is **shape only**. Put an adapted call in your mod") +line("entry file. Replace `...` and names such as `fn` with real values, and") +line("supply every field marked required when registering a new record.") + +local function tableCell(value) + return tostring(value):gsub("|", "\\|") +end -- the value schema of one spec, whichever generation's shape it carries. -- Schemas.check reads keys/keyValue, then value, then fields in that order, @@ -66,7 +101,7 @@ local function renderSchema(spec) for keyName in pairs(spec.keys) do keyNames[#keyNames + 1] = keyName end table.sort(keyNames) for _, keyName in ipairs(keyNames) do - line("| `%s` | %s |", keyName, spec.keys[keyName].desc) + line("| `%s` | %s |", keyName, tableCell(spec.keys[keyName].desc)) end elseif spec.fields then line("") @@ -77,7 +112,7 @@ local function renderSchema(spec) table.sort(fieldNames) for _, fieldName in ipairs(fieldNames) do local ft = spec.fields[fieldName] - line("| `%s` | %s | %s |", fieldName, ft.desc, + line("| `%s` | %s | %s |", fieldName, tableCell(ft.desc), ft.kind == "opt" and "no" or "yes") end elseif spec.keyValue then @@ -98,6 +133,7 @@ end local function renderExample(example, notes) if example then line("") + line("") line("```lua") line("%s", example) line("```") @@ -118,6 +154,12 @@ for _, name in ipairs(names) do if spec.deprecated then line("- **deprecated** -- use %s", spec.deprecated.useInstead) end + if name == "link_fields" then + line("") + line("**Cart protocol only:** this registry does not make a freely enabled") + line("loose mod eligible for player link play. Player links are set up in the") + line("launcher with a vanilla cart or a sealed custom cart.") + end -- The mirror of the Gen 2 gating below. Six registries exist because GOLD -- does (the phone book, the decorations, the radio dial), so they carry no -- Gen 1 target and "- target: none" on its own reads as a broken entry