mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 03:02:39 +02:00
G2 support
This commit is contained in:
+213
-16
@@ -13,6 +13,15 @@
|
||||
-- generated paths that differ between two otherwise identical machines),
|
||||
-- names, dex entries, learnsets and TM/HM lists (they change no battle math
|
||||
-- and no trade rebuild).
|
||||
--
|
||||
-- Two generations, two surfaces. Gold's link surface is the same IDEA over
|
||||
-- different tables -- statuses live at data.gen2Statuses, the special stat is
|
||||
-- two stats, the exp curves are data rather than code, and held items exist at
|
||||
-- all -- so the Gen 2 arm below is a second surface writer, not a widened Gen 1
|
||||
-- one. Widening would have moved the Gen 1 digest, which is pinned by
|
||||
-- tests/engine/gate_fingerprint.lua and by every installed build in the wild.
|
||||
-- docs/gen2-link-design.md section 5 is the field-by-field reasoning for what
|
||||
-- the Gen 2 surface covers and what it deliberately leaves out.
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
@@ -152,20 +161,124 @@ local RECORD_FIELDS = { pokemon = SPECIES_FIELDS, moves = MOVE_FIELDS,
|
||||
|
||||
Fingerprint.FIELDS = RECORD_FIELDS
|
||||
|
||||
local function writeSection(out, data, kind)
|
||||
local map = data[kind]
|
||||
-- ------- the Gen 2 link surface
|
||||
--
|
||||
-- Same doctrine, applied to Gold's records. Every difference from the Gen 1
|
||||
-- lists above is a real Gen 2 change rather than an extractor spelling:
|
||||
--
|
||||
-- baseStats carries specialAttack/specialDefense instead of special
|
||||
-- (pokegold data/pokemon/base_stats/), which writeValue hashes
|
||||
-- by sorted key without needing to know either name
|
||||
-- genderRatio Gen 2 has ATTRACT, so two peers that disagree on a species'
|
||||
-- gender split disagree on whether a move lands. Nothing else
|
||||
-- out of the breeding block is here: the Day-Care is local,
|
||||
-- there is no link breeding, and an egg's contents are decided
|
||||
-- before it can be traded.
|
||||
-- evolutions points at `into` rather than `species` and carries the
|
||||
-- happiness window / stat comparison; it decides what a traded
|
||||
-- mon becomes, exactly as on Gen 1
|
||||
--
|
||||
-- catchRate stays out for the reason #511 gives, and so does the whole
|
||||
-- eggGroups/eggMoves/eggSteps block, `items` (the wild held-item slots, rolled
|
||||
-- before a link session can see them) and tmhm.
|
||||
local GEN2_SPECIES_FIELDS = { "baseStats", "types", "baseExp",
|
||||
"growthRate", "evolutions", "genderRatio" }
|
||||
-- effectChance is the one addition: Gen 1 encodes a secondary effect's odds in
|
||||
-- the effect itself, Gen 2 stores them per move (pokegold data/moves/moves.asm
|
||||
-- `move` macro, the effect chance byte), so two peers that disagree about
|
||||
-- BODY SLAM's 30 percent disagree about the battle. The rest of the Gen 1
|
||||
-- list rides along unchanged: those keys are absent from an extracted Gold
|
||||
-- record, and writeFields skips an absent field, so they cost nothing and
|
||||
-- cover a mod that sets one.
|
||||
local GEN2_MOVE_FIELDS = { "power", "type", "accuracy", "pp", "effect",
|
||||
"effectChance", "category", "priority", "highCrit",
|
||||
"fixedDamage", "multiHit", "counterable",
|
||||
"semiInvulnerable" }
|
||||
-- the same six the Gen 1 statuses carry: src/mods/Schemas.lua R.statuses is one
|
||||
-- spec for both games, and Gold's own records (src/battle/gen2/Battle.lua
|
||||
-- STATUS_RECORDS) fill exactly these
|
||||
local GEN2_STATUS_FIELDS = STATUS_FIELDS
|
||||
-- `status` beside kind: a Gen 2 move_effects record is
|
||||
-- { kind = "primary"/"secondary", status = "burn" } for every status-inflicting
|
||||
-- effect (src/battle/gen2/Battle.lua MOVE_EFFECT_RECORDS), so the status a
|
||||
-- given effect inflicts is part of the surface rather than part of the handler
|
||||
local GEN2_EFFECT_FIELDS = { "rev", "kind", "accuracyChecked", "status" }
|
||||
-- ItemAttributes' last two columns. Pure battle math (Leftovers' heal, King's
|
||||
-- Rock's odds, a type booster's percentage) and the item rides along with a
|
||||
-- traded mon, which makes it trade surface too.
|
||||
local GEN2_HELD_FIELDS = { "rev", "heldEffect", "heldParameter" }
|
||||
-- The exp curve coefficients, straight off pokegold data/growth_rates.asm. On
|
||||
-- Gen 1 this is code (src/pokemon/Growth.lua) and cannot be hashed at all; on
|
||||
-- Gold it is data the extractor writes, and it decides what level a traded
|
||||
-- mon's experience buys, so it is surface.
|
||||
local GEN2_GROWTH_FIELDS = { "numerator", "denominator", "squared", "linear",
|
||||
"constant" }
|
||||
|
||||
local GEN2_RECORD_FIELDS = { pokemon = GEN2_SPECIES_FIELDS,
|
||||
moves = GEN2_MOVE_FIELDS,
|
||||
statuses = GEN2_STATUS_FIELDS,
|
||||
move_effects = GEN2_EFFECT_FIELDS,
|
||||
held_items = GEN2_HELD_FIELDS,
|
||||
growth_rates = GEN2_GROWTH_FIELDS }
|
||||
|
||||
Fingerprint.GEN2_FIELDS = GEN2_RECORD_FIELDS
|
||||
|
||||
-- the allowlist table for a generation; unknown generations read as Gen 1, the
|
||||
-- same default GameVersion.generation() carries
|
||||
local function fieldsFor(generation)
|
||||
if generation == 2 then return GEN2_RECORD_FIELDS end
|
||||
return RECORD_FIELDS
|
||||
end
|
||||
|
||||
-- ------- which generation a merged dataset belongs to
|
||||
--
|
||||
-- Read off the data rather than off GameVersion, for two reasons: this file is
|
||||
-- loaded by tools and headless tests that never boot a game (see the FNV
|
||||
-- comment above), and a caller that hands over a fixture dataset should get a
|
||||
-- digest for THAT dataset rather than for whatever the process last booted.
|
||||
--
|
||||
-- data.type_chart.generation is written by the Gen 2 extractor and is the
|
||||
-- cheapest honest answer. The namespace check behind it covers a dataset
|
||||
-- assembled without a type chart: gen2Statuses/gen2MoveEffects/gen2Constants
|
||||
-- are Data keys only a Gen 2 boot ever creates (src/core/Game2.lua:load and
|
||||
-- src/mods/Builtins.lua's Gen 2 registrants), and Schemas.GEN1 gates every one
|
||||
-- of the Gen 2-only registries to false, so a Red boot cannot grow one.
|
||||
function Fingerprint.generationOf(data)
|
||||
if type(data) ~= "table" then return 1 end
|
||||
local chart = data.type_chart
|
||||
if type(chart) == "table" and tonumber(chart.generation) then
|
||||
return tonumber(chart.generation)
|
||||
end
|
||||
if data.gen2Statuses or data.gen2MoveEffects or data.gen2Constants then
|
||||
return 2
|
||||
end
|
||||
return 1
|
||||
end
|
||||
|
||||
-- data.pokemon on Gold carries one sibling that is not a species: the
|
||||
-- extractor's `growthRates` coefficient rows, which src/battle/gen2/Mon.lua
|
||||
-- reads through growthFor. It gets its own section in the surface, and it is
|
||||
-- skipped here so the species id space -- which Fingerprint.records hands to
|
||||
-- Protocol.eligibleParty as "the mons the peer can rebuild" -- never carries an
|
||||
-- id no party slot could hold.
|
||||
local NON_SPECIES = { growthRates = true }
|
||||
|
||||
local function writeRecords(out, map, label, fields, skip)
|
||||
if map == nil then return end
|
||||
local fields = RECORD_FIELDS[kind]
|
||||
out[#out + 1] = "[" .. kind .. "]"
|
||||
out[#out + 1] = "[" .. label .. "]"
|
||||
for _, id in ipairs(sortedIds(map)) do
|
||||
local record = map[id]
|
||||
if type(record) == "table" then
|
||||
if type(record) == "table" and not (skip and skip[id]) then
|
||||
out[#out + 1] = "@" .. id
|
||||
writeFields(out, record, fields)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function writeSection(out, data, kind)
|
||||
writeRecords(out, data[kind], kind, RECORD_FIELDS[kind])
|
||||
end
|
||||
|
||||
-- the chart rows are an ordered array whose order the merge rebuilds from
|
||||
-- registration history, so they hash in place; the type records ride along
|
||||
-- because `category` decides the physical/special split
|
||||
@@ -186,6 +299,22 @@ local function writeTypeChart(out, data)
|
||||
end
|
||||
end
|
||||
|
||||
-- Gold's chart carries one extra ordered array: the matchups FORESIGHT
|
||||
-- rewrites, which is how a Normal or Fighting move reaches a Ghost at all
|
||||
-- (pokegold data/types/foresight_matchups.asm, read through
|
||||
-- BattleCheckTypeMatchup's `.foresight` arm in engine/battle/effect_commands.asm).
|
||||
-- Two peers that disagree about it disagree about a turn, so it is surface.
|
||||
local function writeGen2TypeChart(out, data)
|
||||
writeTypeChart(out, data)
|
||||
local chart = data.type_chart
|
||||
if not chart or not chart.foresightMatchups then return end
|
||||
out[#out + 1] = "[foresight]"
|
||||
for _, row in ipairs(chart.foresightMatchups) do
|
||||
out[#out + 1] = ("@%s>%s"):format(tostring(row.attacker), tostring(row.defender))
|
||||
writeValue(out, row.multiplier)
|
||||
end
|
||||
end
|
||||
|
||||
local function writeConstants(out, data)
|
||||
if not data.constants then return end
|
||||
out[#out + 1] = "[constants]"
|
||||
@@ -229,7 +358,7 @@ Fingerprint.modKey = modKey
|
||||
-- entry to link play, and vanilla single-player must not pay for it at all
|
||||
local cache = setmetatable({}, { __mode = "k" })
|
||||
|
||||
local function surface(data, mods)
|
||||
local function surfaceGen1(data, mods)
|
||||
local out = {}
|
||||
writeSection(out, data, "pokemon")
|
||||
writeSection(out, data, "moves")
|
||||
@@ -242,16 +371,67 @@ local function surface(data, mods)
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
-- The Gen 2 surface. Opens with a "[gen2]" tag so a Gen 2 digest can never
|
||||
-- collide with a Gen 1 one even over degenerate data -- checkCompat refuses a
|
||||
-- cross-generation pairing by the hello's `generation` field long before the
|
||||
-- digests are compared, and this makes the digest agree with that refusal
|
||||
-- instead of leaving it to luck.
|
||||
--
|
||||
-- data.gen2Constants is deliberately absent, and it is the one omission worth
|
||||
-- spelling out: it is the ROM's ordered NAME lists (speciesOrder, itemOrder,
|
||||
-- heldEffectOrder, mapOrder...), an index space the extractor uses, and every
|
||||
-- dispatch in the Gen 2 simulation goes by name -- Battle.heldEffect compares
|
||||
-- record.heldEffect strings, moveEffectRecordFor keys by EFFECT_*. Reordering
|
||||
-- one moves no battle math, so hashing it would split two peers over a table
|
||||
-- neither of them dispatches on, which is the #511 mistake in a new place.
|
||||
-- Balls and item_effects stay out for the reason the Gen 1 surface leaves them
|
||||
-- out: no link mode lets a bag item be thrown.
|
||||
local function surfaceGen2(data, mods)
|
||||
local out = { "[gen2]" }
|
||||
writeRecords(out, data.pokemon, "pokemon", GEN2_SPECIES_FIELDS, NON_SPECIES)
|
||||
-- the growth curves live on the species map as a sibling of the species
|
||||
-- records (data.pokemon.growthRates, written by the extractor and read by
|
||||
-- src/battle/gen2/Mon.lua:growthFor), so they hash as their own section
|
||||
-- rather than as a species with no fields
|
||||
writeRecords(out, data.pokemon and data.pokemon.growthRates,
|
||||
"growth_rates", GEN2_GROWTH_FIELDS)
|
||||
writeRecords(out, data.moves, "moves", GEN2_MOVE_FIELDS)
|
||||
writeGen2TypeChart(out, data)
|
||||
writeRecords(out, data.gen2Statuses, "statuses", GEN2_STATUS_FIELDS)
|
||||
writeRecords(out, data.gen2MoveEffects, "move_effects", GEN2_EFFECT_FIELDS)
|
||||
writeRecords(out, data.gen2HeldItems, "held_items", GEN2_HELD_FIELDS)
|
||||
out[#out + 1] = "[mods]" .. modKey(mods)
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
-- `generation` is optional everywhere: absent means "ask the data"
|
||||
-- (Fingerprint.generationOf), which is what every caller but a test does.
|
||||
local function surface(data, mods, generation)
|
||||
if (generation or Fingerprint.generationOf(data)) == 2 then
|
||||
return surfaceGen2(data, mods)
|
||||
end
|
||||
return surfaceGen1(data, mods)
|
||||
end
|
||||
|
||||
Fingerprint.surface = surface
|
||||
|
||||
-- mods: { { id, version, affectsLink } } -- the hello's mod array
|
||||
function Fingerprint.compute(data, mods)
|
||||
function Fingerprint.compute(data, mods, generation)
|
||||
if not data then return digest("") end
|
||||
local key = modKey(mods)
|
||||
generation = generation or Fingerprint.generationOf(data)
|
||||
-- the generation rides in the memo key: one dataset asked for both digests
|
||||
-- (a test, a tool) must not be handed the other one back
|
||||
local key = modKey(mods) .. "|" .. tostring(generation)
|
||||
local hit = cache[data]
|
||||
if hit and hit.key == key then return hit.value end
|
||||
-- The hook keeps its Gen 1 name AND its Gen 1 arity. `generation` is
|
||||
-- captured by the closure rather than passed as a third argument, so a mod
|
||||
-- that wraps link.fingerprint and forwards nxt(data, mods) -- the shape
|
||||
-- docs/modding.md documents and tests/mod_link_tests.lua exercises -- keeps
|
||||
-- working verbatim on Gold instead of silently dropping the argument and
|
||||
-- computing a Gen 1 digest over Gen 2 data.
|
||||
local value = Runtime.call("link.fingerprint", function(d, m)
|
||||
return digest(surface(d, m))
|
||||
return digest(surface(d, m, generation))
|
||||
end, data, mods)
|
||||
cache[data] = { key = key, value = value }
|
||||
return value
|
||||
@@ -261,25 +441,42 @@ end
|
||||
-- exactly which species and moves they rebuild identically
|
||||
local recordCache = setmetatable({}, { __mode = "k" })
|
||||
|
||||
function Fingerprint.records(data, kind)
|
||||
local fields = RECORD_FIELDS[kind]
|
||||
assert(fields, "no record allowlist for " .. tostring(kind))
|
||||
-- The Data path a record kind reads from for a generation. Only the Gen 2
|
||||
-- side ever differs, and only for the registries Schemas.GEN2 namespaces.
|
||||
local GEN2_PATHS = { statuses = "gen2Statuses", move_effects = "gen2MoveEffects",
|
||||
held_items = "gen2HeldItems" }
|
||||
|
||||
local function recordMap(data, kind, generation)
|
||||
if generation == 2 then
|
||||
local path = GEN2_PATHS[kind]
|
||||
if path then return data[path] end
|
||||
end
|
||||
return data[kind]
|
||||
end
|
||||
|
||||
function Fingerprint.records(data, kind, generation)
|
||||
generation = generation or Fingerprint.generationOf(data)
|
||||
local fields = fieldsFor(generation)[kind]
|
||||
assert(fields, ("no record allowlist for %s (generation %s)")
|
||||
:format(tostring(kind), tostring(generation)))
|
||||
local perData = recordCache[data]
|
||||
if not perData then
|
||||
perData = {}
|
||||
recordCache[data] = perData
|
||||
end
|
||||
if perData[kind] then return perData[kind] end
|
||||
local map = data[kind] or {}
|
||||
local slot = kind .. "|" .. tostring(generation)
|
||||
if perData[slot] then return perData[slot] end
|
||||
local map = recordMap(data, kind, generation) or {}
|
||||
local skip = (generation == 2 and kind == "pokemon") and NON_SPECIES or nil
|
||||
local out = {}
|
||||
for id, record in pairs(map) do
|
||||
if type(record) == "table" then
|
||||
if type(record) == "table" and not (skip and skip[id]) then
|
||||
local buf = { "@" .. id }
|
||||
writeFields(buf, record, fields)
|
||||
out[id] = digest(table.concat(buf))
|
||||
end
|
||||
end
|
||||
perData[kind] = out
|
||||
perData[slot] = out
|
||||
return out
|
||||
end
|
||||
|
||||
|
||||
+69
-4
@@ -16,11 +16,31 @@ local Handshake = {}
|
||||
Handshake.PROTOCOL = Version.linkProtocol or 2
|
||||
|
||||
-- writing into any of these changes what a lockstep turn or a rebuilt trade
|
||||
-- mon looks like, which is what a v1 peer cannot know about us
|
||||
-- mon looks like, which is what a v1 peer cannot know about us. Registry
|
||||
-- NAMES, not Data paths, so one list covers both generations: `statuses` means
|
||||
-- data.statuses on Red and data.gen2Statuses on Gold (Schemas.GEN2), and
|
||||
-- mod.content.statuses is the one thing a mod ever names.
|
||||
--
|
||||
-- held_items is Gen 2-only and is here for the same reason the rest are: a
|
||||
-- Gold mod that changes what LEFTOVERS heals has changed the battle, and the
|
||||
-- item travels on a traded mon. On Red the registry is gated to false
|
||||
-- (Schemas.GEN1), so no op can land in it and the row costs a Gen 1 boot
|
||||
-- nothing.
|
||||
--
|
||||
-- growth_rates is here because it is the one link-surface registry whose
|
||||
-- records the fingerprint cannot hash: a curve is an expForLevel FUNCTION
|
||||
-- (src/mods/Schemas.lua R.growth_rates), and writeValue serializes a function
|
||||
-- as "?". It decides what level a traded mon's experience buys -- Gen 1 reads
|
||||
-- it through src/pokemon/Growth.lua and Gold through Mon.growthFor, which
|
||||
-- prefers the merged registry over the extractor's own coefficient rows -- so
|
||||
-- two peers that disagree about a curve rebuild the same traded mon at
|
||||
-- different levels. Without this row a mod declaring affects_link = false
|
||||
-- could rewrite every curve and be caught by neither the digest (modKey skips
|
||||
-- it on its own say-so) nor the online gate.
|
||||
local LINK_SURFACE = {
|
||||
pokemon = true, moves = true, type_chart = true, statuses = true,
|
||||
move_effects = true, balls = true, rulesets = true, constants = true,
|
||||
link_fields = true,
|
||||
link_fields = true, held_items = true, growth_rates = true,
|
||||
}
|
||||
|
||||
Handshake.LINK_SURFACE = LINK_SURFACE
|
||||
@@ -146,10 +166,20 @@ function Handshake.onlineAllowed(game)
|
||||
return #Handshake.onlineBlockers(game) == 0
|
||||
end
|
||||
|
||||
-- Which generation this install is running, read off the merged dataset rather
|
||||
-- than off GameVersion, so a headless harness that hands over a fixture gets an
|
||||
-- answer about THAT dataset (Fingerprint.generationOf spells out the two
|
||||
-- signals it reads). A game with no data at all is Gen 1, which is what every
|
||||
-- pre-Gold build was.
|
||||
function Handshake.generation(game)
|
||||
return Fingerprint.generationOf(game and game.data)
|
||||
end
|
||||
|
||||
-- mode is nil on the guest: it pairs and announces itself before the host
|
||||
-- has picked, and compatibility is decided from the two hellos, not the mode
|
||||
function Handshake.hello(game, mode)
|
||||
local mods = Handshake.mods(game)
|
||||
local generation = Handshake.generation(game)
|
||||
return {
|
||||
type = "hello",
|
||||
protocol = Handshake.PROTOCOL,
|
||||
@@ -157,7 +187,11 @@ function Handshake.hello(game, mode)
|
||||
mode = mode,
|
||||
engineVersion = Version.engine,
|
||||
apiVersion = Version.modApi,
|
||||
fingerprint = Fingerprint.compute(game and game.data, mods),
|
||||
-- additive, like every other field here: a peer that omits `generation` is
|
||||
-- Gen 1 by construction, because no build that shipped without this field
|
||||
-- could link as anything else (docs/gen2-link-design.md section 4)
|
||||
generation = generation,
|
||||
fingerprint = Fingerprint.compute(game and game.data, mods, generation),
|
||||
linkModified = Handshake.linkModified(game),
|
||||
mods = mods,
|
||||
}
|
||||
@@ -172,9 +206,27 @@ end
|
||||
-- engine_skew both v2 on the same major, but different releases: trade
|
||||
-- still negotiates, battle is refused (see below)
|
||||
-- subset both v2 but the surfaces differ: negotiated trade, no battle
|
||||
-- refused an old build we would silently corrupt, or a different engine
|
||||
-- refused an old build we would silently corrupt, a different engine, or a
|
||||
-- peer running the other generation
|
||||
function Handshake.checkCompat(localHello, remoteHello)
|
||||
localHello = localHello or {}
|
||||
-- Generation first, ahead of the v1 branch below: a Gold install meeting a
|
||||
-- pre-Gold build has to refuse it as the wrong GAME, not read its missing
|
||||
-- `protocol` as "peer is vanilla Red and is right about us".
|
||||
--
|
||||
-- The cart's answer to a cross-generation cable was the Time Capsule, and it
|
||||
-- is not a compatibility mode: CheckTimeCapsuleCompatibility
|
||||
-- (pokegold engine/link/link.asm:1970) refuses any Johto species, any move
|
||||
-- past STRUGGLE and any mon holding mail, and only then does
|
||||
-- Link_PrepPartyData_Gen1 rewrite the whole party into Red's 44-byte struct
|
||||
-- with the Special stat recomputed out of KantoMonSpecials. Until somebody
|
||||
-- writes that conversion and its two validators, refusing the pairing is the
|
||||
-- honest answer -- docs/gen2-link-design.md section 6.
|
||||
local localGen = localHello.generation or 1
|
||||
local remoteGen = (remoteHello and remoteHello.generation) or 1
|
||||
if localGen ~= remoteGen then
|
||||
return "refused", "generation_mismatch"
|
||||
end
|
||||
if not remoteHello or not remoteHello.protocol then
|
||||
if localHello.linkModified then
|
||||
return "refused", "peer_v1_modified"
|
||||
@@ -277,6 +329,19 @@ function Handshake.describe(localHello, remoteHello, verdict, mode)
|
||||
local lines = {}
|
||||
local peer = (remoteHello and remoteHello.name) or "THEY"
|
||||
if verdict == "refused" then
|
||||
-- checked before the v1 arm for the same reason checkCompat checks it
|
||||
-- first: a Gen 1 peer meeting a Gen 2 one has no `protocol` to read yet
|
||||
-- would be named as "an older version", which is the wrong sentence and
|
||||
-- sends the player looking for an update that does not exist
|
||||
if ((localHello and localHello.generation) or 1)
|
||||
~= ((remoteHello and remoteHello.generation) or 1) then
|
||||
wrap(lines, "The other game is")
|
||||
wrap(lines, "from a different")
|
||||
wrap(lines, "generation.")
|
||||
wrap(lines, "These two games")
|
||||
wrap(lines, "can't link.")
|
||||
return lines
|
||||
end
|
||||
if not (remoteHello and remoteHello.protocol) then
|
||||
wrap(lines, "The other game is")
|
||||
wrap(lines, "an older version")
|
||||
|
||||
+236
-5
@@ -162,6 +162,200 @@ function Protocol.unpackMon(data, packed, opts)
|
||||
}
|
||||
end
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- The Gen 2 party struct on the wire
|
||||
--
|
||||
-- A SECOND codec rather than optional keys on the Gen 1 one, because every
|
||||
-- field the two share is spelled differently or means something else --
|
||||
-- docs/gen2-link-design.md section 3 has the table. The sharpest of them is
|
||||
-- `status`: Gen 1 writes "PSN"/"BRN"/"SLP" (src/battle/Status.lua:62) and
|
||||
-- Gen 2 writes "poison"/"burn"/"sleep" (src/battle/gen2/Battle.lua:65), and a
|
||||
-- shared codec would hand a Gold party a status string nothing in it
|
||||
-- recognises -- a mon that arrives poisoned and never takes poison damage.
|
||||
--
|
||||
-- Nothing sends these yet. They exist because the mapping is the part of a
|
||||
-- Gen 2 trade that is decidable today and because a wrong guess here would
|
||||
-- silently corrupt a traded mon later; the session, the UI and mail are listed
|
||||
-- as not built in the design doc's section 7.
|
||||
--
|
||||
-- The cart's own party block is `Link_PrepPartyData_Gen2`
|
||||
-- (pokegold engine/link/link.asm:810): player name, party count and species
|
||||
-- list, trainer ID, six PARTYMON_STRUCT_LENGTH structs, six OT names, six
|
||||
-- nicknames -- and, in the Trade Center only, mail as a SEPARATE block copied
|
||||
-- out of sPartyMail. Mail stays separate here for the same reason: it is not
|
||||
-- a party-struct field (src/core/gen2/Mail.lua:84 keys it by party slot), and
|
||||
-- packing it onto the mon would invent a shape the cart does not have.
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
-- Gen 2 rolls four DVs and DERIVES the HP DV from their low bits
|
||||
-- (Mon.hpDV, and pokegold's own GetMonDVs does the same shuffle), so the
|
||||
-- hp entry never travels: sending it would let a tampered packet claim an HP
|
||||
-- DV its four visible DVs cannot produce.
|
||||
local GEN2_DVS = { "attack", "defense", "speed", "special" }
|
||||
-- MON_STAT_EXP's five words, in struct order; src/battle/gen2/Mon.lua's
|
||||
-- STAT_EXP_ORDER is the authority and there is no sixth (SpA and SpD share the
|
||||
-- Special word, the way the Gen 1 struct left them)
|
||||
local GEN2_STAT_EXP = { "hp", "attack", "defense", "speed", "special" }
|
||||
|
||||
function Protocol.packMon2(mon)
|
||||
local moves = {}
|
||||
for _, mv in ipairs(mon.moves or {}) do
|
||||
-- ppUps has no Gen 2 model yet (Mon.movesAtLevel writes id/pp/maxPp);
|
||||
-- carried when present so a mod that adds one is not silently capped, the
|
||||
-- same reasoning packMon gives for the Gen 1 field
|
||||
table.insert(moves, { id = mv.id, pp = mv.pp, ppUps = mv.ppUps })
|
||||
end
|
||||
local dvs = {}
|
||||
for _, k in ipairs(GEN2_DVS) do dvs[k] = (mon.dvs or {})[k] end
|
||||
local statExp = {}
|
||||
for _, k in ipairs(GEN2_STAT_EXP) do statExp[k] = (mon.statExp or {})[k] end
|
||||
return {
|
||||
species = mon.species,
|
||||
level = mon.level,
|
||||
-- MON_EXP, and the field is `experience` on a Gen 2 mon, not `exp`
|
||||
experience = mon.experience,
|
||||
hp = mon.hp,
|
||||
status = mon.status,
|
||||
nickname = mon.nickname,
|
||||
dvs = dvs,
|
||||
statExp = statExp,
|
||||
moves = moves,
|
||||
-- MON_ITEM. The held item is battle math (Leftovers, King's Rock, the
|
||||
-- type boosters) and it travels with the mon, which is why held_items is
|
||||
-- link surface in the fingerprint.
|
||||
item = mon.item,
|
||||
-- MON_HAPPINESS / MON_PKRS, both of which the cart ships inside the party
|
||||
-- struct and both of which outlive a trade
|
||||
happiness = mon.happiness,
|
||||
pokerus = mon.pokerus,
|
||||
caughtLevel = mon.caughtLevel,
|
||||
ot = mon.ot or mon.otName,
|
||||
otId = mon.otId,
|
||||
-- an egg is a party slot the cart marks by writing EGG into wPartySpecies;
|
||||
-- the port marks it with isEgg instead (src/core/gen2/Breeding.lua:64)
|
||||
isEgg = mon.isEgg or nil,
|
||||
eggSteps = mon.isEgg and mon.eggSteps or nil,
|
||||
extra = plainCopy(mon.extra),
|
||||
}
|
||||
end
|
||||
|
||||
-- Rebuild a Gen 2 mon locally. Same contract as unpackMon: every number is
|
||||
-- clamped and every derived value is RECOMPUTED from real species data, so a
|
||||
-- tampered packet can invent neither stats nor a shiny. opts.strict refuses by
|
||||
-- name instead of substituting once two v2 peers have agreed on a verdict.
|
||||
function Protocol.unpackMon2(data, packed, opts)
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local strict = opts and opts.strict
|
||||
local forceLevel = opts and tonumber(opts.forceLevel) or nil
|
||||
local def = data and data.pokemon and data.pokemon[packed.species]
|
||||
if not def then
|
||||
if strict then return nil, "unknown POKéMON" end
|
||||
return nil
|
||||
end
|
||||
local level = math.max(1, math.min(Mon.MAX_LEVEL,
|
||||
math.floor(packed.level or 5)))
|
||||
if forceLevel then
|
||||
level = math.max(1, math.min(Mon.MAX_LEVEL, math.floor(forceLevel)))
|
||||
end
|
||||
local dvs = {}
|
||||
for _, k in ipairs(GEN2_DVS) do
|
||||
dvs[k] = math.max(0, math.min(Mon.MAX_DV,
|
||||
math.floor((packed.dvs or {})[k] or 0)))
|
||||
end
|
||||
-- derived, never taken from the packet (see GEN2_DVS above)
|
||||
dvs.hp = Mon.hpDV(dvs)
|
||||
local statExp = {}
|
||||
for _, k in ipairs(GEN2_STAT_EXP) do
|
||||
statExp[k] = math.max(0, math.min(65535,
|
||||
math.floor((packed.statExp or {})[k] or 0)))
|
||||
end
|
||||
local stats = Mon.stats(def.baseStats, dvs, level, statExp)
|
||||
local moves = {}
|
||||
for _, mv in ipairs(packed.moves or {}) do
|
||||
local mdef = data.moves and data.moves[mv.id]
|
||||
if mdef and #moves < 4 then
|
||||
local ppUps = math.max(0, math.min(3, math.floor(mv.ppUps or 0)))
|
||||
local maxPp = (mdef.pp or 0) + ppUps * math.floor((mdef.pp or 0) / 5)
|
||||
local entry = { id = mv.id, maxPp = maxPp,
|
||||
pp = math.max(0, math.min(maxPp, math.floor(mv.pp or 0))) }
|
||||
if mv.ppUps ~= nil then entry.ppUps = ppUps end
|
||||
table.insert(moves, entry)
|
||||
end
|
||||
end
|
||||
if #moves == 0 then
|
||||
if strict then return nil, "no shared moves" end
|
||||
-- the Gen 1 substitute, in Gen 2's move-entry shape
|
||||
local tackle = data.moves and data.moves.TACKLE
|
||||
moves = { { id = "TACKLE", pp = (tackle and tackle.pp) or 35,
|
||||
maxPp = (tackle and tackle.pp) or 35 } }
|
||||
end
|
||||
-- An item the receiving game has never heard of cannot be held: the battle
|
||||
-- would read no heldEffect for it and the bag would show a blank row. This
|
||||
-- is the same judgement CheckTimeCapsuleCompatibility makes from the other
|
||||
-- side (pokegold engine/link/link.asm:1970 refuses mail rather than shipping
|
||||
-- an item the peer cannot represent), and the Gen 2 arm of
|
||||
-- Protocol.eligibleParty is what keeps it from ever reaching here on a
|
||||
-- negotiated trade.
|
||||
local item = packed.item
|
||||
if item ~= nil and not (data.items and data.items[item]) then
|
||||
if strict then return nil, "unknown item" end
|
||||
item = nil
|
||||
end
|
||||
local forced = forceLevel
|
||||
local hp = forced and stats.hp
|
||||
or math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp)))
|
||||
local status = forced and nil or packed.status
|
||||
local otId = packed.otId
|
||||
and math.max(0, math.min(65535, math.floor(packed.otId))) or nil
|
||||
local ot = type(packed.ot) == "string" and packed.ot:sub(1, 10) or nil
|
||||
local growth = Mon.growthFor(data, def.growthRate)
|
||||
local mon = {
|
||||
species = packed.species,
|
||||
name = def.name or packed.species,
|
||||
nickname = packed.nickname,
|
||||
level = level,
|
||||
experience = math.max(0, math.floor(packed.experience
|
||||
or Mon.experienceForLevel(growth, level))),
|
||||
dvs = dvs,
|
||||
statExp = statExp,
|
||||
stats = stats,
|
||||
hp = hp,
|
||||
maxHp = stats.hp,
|
||||
types = def.types,
|
||||
status = status,
|
||||
moves = moves,
|
||||
item = item,
|
||||
-- GiveEgg starts a hatched mon at 120 and a caught one at 70; a traded mon
|
||||
-- keeps what it arrived with, clamped to the byte the cart stores it in
|
||||
happiness = math.max(0, math.min(255,
|
||||
math.floor(packed.happiness or 70))),
|
||||
pokerus = math.max(0, math.min(255, math.floor(packed.pokerus or 0))),
|
||||
caughtLevel = math.max(1, math.min(Mon.MAX_LEVEL,
|
||||
math.floor(packed.caughtLevel or level))),
|
||||
ot = ot,
|
||||
otName = ot,
|
||||
otId = otId,
|
||||
extra = plainCopy(packed.extra),
|
||||
}
|
||||
if packed.isEgg then
|
||||
mon.isEgg = true
|
||||
mon.eggSteps = math.max(0, math.floor(packed.eggSteps or 0))
|
||||
end
|
||||
-- Derived from the DVs on the RECEIVING side, exactly as they were derived on
|
||||
-- the sending one: shininess, gender and an Unown's letter are all functions
|
||||
-- of the same four bytes (Mon.isShiny / Mon.gender / Unown.letterFromDVs), so
|
||||
-- sending them would only give a patched client a way to claim a shiny it
|
||||
-- never rolled.
|
||||
local ctx = { species = packed.species, def = def, level = level }
|
||||
mon.shiny = Mon.isShiny(dvs, ctx)
|
||||
mon.gender = Mon.gender(def, dvs, ctx)
|
||||
local Unown = require("src.core.gen2.Unown")
|
||||
if packed.species == Unown.SPECIES then
|
||||
mon.unownLetter = Unown.letterFromDVs(dvs)
|
||||
end
|
||||
return mon
|
||||
end
|
||||
|
||||
function Protocol.packParty(party, indices)
|
||||
local mons = {}
|
||||
if indices then
|
||||
@@ -185,9 +379,20 @@ end
|
||||
-- games showed neither side (#511). The full catalog is ~300 short hash
|
||||
-- strings -- still one message.
|
||||
function Protocol.recordsMessage(data, party)
|
||||
return { type = "records",
|
||||
pokemon = Fingerprint.records(data, "pokemon"),
|
||||
moves = Fingerprint.records(data, "moves") }
|
||||
local generation = Fingerprint.generationOf(data)
|
||||
local msg = { type = "records",
|
||||
pokemon = Fingerprint.records(data, "pokemon", generation),
|
||||
moves = Fingerprint.records(data, "moves", generation) }
|
||||
-- One more map on Gen 2, and additive by the same rule the hello follows: a
|
||||
-- peer that never sends `heldItems` is one whose game has no held items, and
|
||||
-- eligibleParty treats an absent map as "nothing to check" so the Gen 1 path
|
||||
-- is byte-identical. A held item is battle math AND it rides along on the
|
||||
-- traded mon, so a mon holding one the peer rebuilds differently is exactly
|
||||
-- as untradeable as a mon that knows a move it rebuilds differently.
|
||||
if generation == 2 then
|
||||
msg.heldItems = Fingerprint.records(data, "held_items", generation)
|
||||
end
|
||||
return msg
|
||||
end
|
||||
|
||||
-- a mon may cross the wire only if both peers rebuild it identically: the
|
||||
@@ -201,12 +406,31 @@ function Protocol.eligibleParty(party, myRecords, theirRecords)
|
||||
local theirMoves = theirRecords.moves or {}
|
||||
local mySpecies = (myRecords or {}).pokemon or {}
|
||||
local myMoves = (myRecords or {}).moves or {}
|
||||
-- Gen 2 only, and absent on both sides of a Gen 1 trade, which is what keeps
|
||||
-- the loop below unchanged for Red: a mon with no `item` never reaches the
|
||||
-- held-item arm at all.
|
||||
local theirHeld = theirRecords.heldItems
|
||||
local myHeld = (myRecords or {}).heldItems or {}
|
||||
for i, mon in ipairs(party or {}) do
|
||||
local reason
|
||||
if not theirSpecies[mon.species] then
|
||||
reason = "not on the other game"
|
||||
elseif theirSpecies[mon.species] ~= mySpecies[mon.species] then
|
||||
reason = "different data"
|
||||
-- Absence before difference, because a missing row on their side reads as
|
||||
-- "different" to a naive comparison and the player would be told the wrong
|
||||
-- thing. Both arms are guarded on myHeld[mon.item]: an item with no held
|
||||
-- behaviour on EITHER game (a POTION in the item slot) is just an id, and
|
||||
-- an id the peer already has by way of the species check.
|
||||
elseif mon.item and theirHeld and myHeld[mon.item]
|
||||
and not theirHeld[mon.item] then
|
||||
-- we hold it AS a held item and they have no such row: the mon would
|
||||
-- arrive holding something their battle cannot read
|
||||
reason = "unknown item"
|
||||
elseif mon.item and theirHeld and myHeld[mon.item]
|
||||
and theirHeld[mon.item] ~= myHeld[mon.item] then
|
||||
-- the item exists on both games but does something else on theirs
|
||||
reason = "different item"
|
||||
else
|
||||
for _, mv in ipairs(mon.moves or {}) do
|
||||
if not theirMoves[mv.id] then
|
||||
@@ -277,9 +501,16 @@ function TradeSession:partyMessage()
|
||||
mons = Protocol.packParty(self.party, self.sendIndices) }
|
||||
end
|
||||
|
||||
-- Our own side of the comparison is built by the SAME function that puts our
|
||||
-- records on the wire, so the two can never drift: an open-coded pair of
|
||||
-- Fingerprint.records calls here left `heldItems` off our side only, and
|
||||
-- eligibleParty's held-item arms are both guarded on myHeld[mon.item] -- so on
|
||||
-- Gen 2 they were unreachable from the only production caller and a mon
|
||||
-- holding an item the peer rebuilds differently sailed through the filter.
|
||||
-- The message's `type` field is inert to eligibleParty, which reads exactly
|
||||
-- the three record maps.
|
||||
function TradeSession:_negotiate(theirRecords)
|
||||
local mine = { pokemon = Fingerprint.records(self.data, "pokemon"),
|
||||
moves = Fingerprint.records(self.data, "moves") }
|
||||
local mine = Protocol.recordsMessage(self.data, self.party)
|
||||
self.eligible, self.reasons =
|
||||
Protocol.eligibleParty(self.party, mine, theirRecords)
|
||||
local indices = {}
|
||||
|
||||
Reference in New Issue
Block a user