diff --git a/README.md b/README.md index de47286..76d3428 100644 --- a/README.md +++ b/README.md @@ -52,12 +52,133 @@ var client = mc.createClient({ autoVersionForge(client); ``` +Explicit mod lists can be supplied to auto-versioning. FML2 and FML3 use mod ID +strings; FML1 uses objects with `modid` and `version` fields. FML3 also accepts +handlers for mod messages inside `fml:loginwrapper`: + +```javascript +autoVersionForge(client, { + forgeMods: ['examplemod'], + channels: { 'examplemod:handshake': '1' }, + loginHandlers: { + 'examplemod:handshake': data => handleExampleModHandshake(data) + } +}) +``` + +Implement `handleExampleModHandshake` using the mod's protocol. It receives the +inner channel payload and returns a Buffer containing the inner response, or +undefined to decline the request. Unhandled mod channels inside the FML3 login +wrapper are declined and emitted as `forgeLoginPluginRequest`. + +Advertising a mod list or acknowledging registry data does not implement the +mod's gameplay, custom packets, or dynamic registry mappings. Applications must +provide those capabilities separately. Login handlers currently apply to FML3; +they do not implement Fabric or modern Forge configuration networking. + +### Modern Forge configuration (experimental) + +The separate `forgeHandshakeModern(client, options)` export implements the +configuration-phase Forge network-version-0 exchange. `autoVersionForge` selects +it when the server advertises `forgeData.fmlNetworkVersion: 0`. The explicit export +is useful when the Minecraft version is supplied directly. Do not install both +handlers on the same client. Unknown network versions are not guessed. + +Options are `modVersions` (objects with `id`, `name`, and `version`), `channels` +(channel names mapped to numeric versions), and `dataPackRegistries` (supported +registry names). Omitted mod versions are reflected from the server. Only the +Forge handshake/login channels are advertised by default. Additional mod-channel +implementations remain the application's responsibility. + +Registry ID entries are exposed in `client.forgeRegistries` and through +`forgeRegistry`; raw config contents are emitted through `forgeConfig`, never +written to disk. Receiving these does not implement the corresponding mod's +gameplay. `client.forgeHandshakeComplete` becomes true after the configuration +exchange completes and the client enters play. + This will automatically install the `forgeHandshake` plugin, with the appropriate mods, if the server advertises itself as Forge/FML. Useful for connecting to servers you don't know if they are Forge or not, or what mods they are using. +### Fabric networking + +`fabricNetworking(client, options)` handles early play-channel registration and +common networking version 1. Supply `configurationHandlers` and `playHandlers` +as objects mapping channel names to functions receiving `(data, { client, phase })`. +Only supplied handlers and the implemented common channels are advertised. +Handlers implement their mod's payload format and any required replies. + +Optional `loginHandlers` map login-query channel names to functions receiving +`(data, { client, messageId })`. Return a Buffer (or a Promise resolving to one) +to reply, or undefined to decline. Unknown queries are declined. Fabric early +registration is handled internally and cannot be overridden. + +The returned object's `remoteChannels` contains separate configuration/play Sets. +`fabricChannels` reports common registration updates. Install this plugin instead +of a Forge/NeoForge handshake on the same client. + +On 1.21.11, optional `registryHandler(registries, { client })` enables Fabric +registry synchronization. `registries` is a Map from namespaced registry names +to `{ optional, entries }`, where `entries` maps namespaced entry names to IDs. +The handler must synchronously validate and apply the mappings to the application's +consumers, then return `true`. Otherwise synchronization fails without acknowledging +completion. `fabricRegistries` is emitted after acceptance. No registry channel +is advertised by default. + +`installFabricRegistryMappings(client, registries, codecs)` can apply synchronized +sound-event IDs and install per-client item-component and particle schemas. `codecs` +contains `dataComponentTypes` and `particleTypes` objects keyed by namespaced registry +entry. Their values are ProtoDef type definitions implementing the mod's exact wire +format. Missing custom codecs, unsupported registries, shared-schema changes, and +conflicts with caller schemas are rejected. Only Minecraft 1.21.11 is currently +verified. For example: + +```javascript +const forge = require('minecraft-protocol-forge') + +forge.fabricNetworking(client, { + registryHandler: registries => forge.installFabricRegistryMappings(client, registries, { + particleTypes: { 'example:spark': ['container', sparkFields] }, + dataComponentTypes: { 'example:mode': 'anonymousNbt' } + }) +}) +``` + +The application must obtain these codecs from the mod's protocol; the library does +not infer them or treat successful registry synchronization as general mod support. + +### NeoForge networking + +`neoforgeHandshake(client, options)` supports the configuration protocols exercised +on Minecraft 1.20.4, 1.21.1 and 1.21.11. Set the Minecraft version when creating the client; +NeoForge automatic detection is not implemented. + +`configurationChannels` and `playChannels` map channel names to objects containing +`version`, `handler`, optional `flow` (`clientbound` or `serverbound`, omitted for +both directions), and `optional` (false by default). Incoming channels require a +handler receiving `(data, { client, phase })`. Server channels are not reflected +automatically. + +Built-in handlers synchronize frozen registry IDs and aliases and install the +command-argument mapping. Snapshots are exposed in `client.neoforgeRegistries` +and through `neoforgeRegistry`. Other gameplay systems must consume these mappings +themselves. On 1.21.1 and 1.21.11, compatibility checks reject unsupported enum extensions and +modded feature flags rather than acknowledging capabilities that are absent. + +On 1.21.11, the default `neoforge:recipe_content` handler accepts only empty +recipe synchronization and emits `neoforgeRecipes`. Nonempty recipe data fails +explicitly; supply a registry-aware `playChannels` handler for mods that sync +recipes. This is not general modded recipe support. + +`client.neoforgeHandshakeComplete` indicates channel negotiation and any negotiated +registry synchronization completed before entering play. It does not certify mod +gameplay support. Configuration pings are answered by default; set +`respondToPing: false` if the application handles those packets itself. + ## Installation +Requires Node.js 22 or newer. + `npm install minecraft-protocol-forge` ## Debugging diff --git a/index.js b/index.js index 7ce2d78..1003320 100644 --- a/index.js +++ b/index.js @@ -2,5 +2,9 @@ module.exports = { forgeHandshake: require('./src/client/forgeHandshake'), + forgeHandshakeModern: require('./src/client/forgeHandshakeModern'), + fabricNetworking: require('./src/client/fabricNetworking'), + installFabricRegistryMappings: require('./src/client/fabricRegistryMappings'), + neoforgeHandshake: require('./src/client/neoforgeHandshake'), autoVersionForge: require('./src/client/autoVersionForge') } diff --git a/package.json b/package.json index de07661..7f1d4ab 100644 --- a/package.json +++ b/package.json @@ -26,9 +26,10 @@ "author": "deathcap", "license": "BSD-3-Clause", "engines": { - "node": ">=4" + "node": ">=22" }, "dependencies": { + "minecraft-data": "^3.109.0", "minecraft-protocol": "^1.43.1", "protodef": "^1.0.0" }, diff --git a/src/client/autoVersionForge.js b/src/client/autoVersionForge.js index 4198000..50074a8 100644 --- a/src/client/autoVersionForge.js +++ b/src/client/autoVersionForge.js @@ -3,11 +3,12 @@ const forgeHandshake = require('./forgeHandshake') const forgeHandshake2 = require('./forgeHandshake2') const forgeHandshake3 = require('./forgeHandshake3') +const forgeHandshakeModern = require('./forgeHandshakeModern') -module.exports = function (client, options) { +module.exports = function (client, forgeOptions = {}) { if (!client.autoVersionHooks) client.autoVersionHooks = [] - client.autoVersionHooks.push(function (response, client, options) { + client.autoVersionHooks.push(function (response, client) { if (!response.modinfo || response.modinfo.type !== 'FML') { return // not ours } @@ -17,32 +18,38 @@ module.exports = function (client, options) { console.log('Using forgeMods:', forgeMods) // Install the FML|HS plugin with the given mods - forgeHandshake(client, { forgeMods }) + forgeHandshake(client, { ...forgeOptions, forgeMods: forgeOptions.forgeMods ?? forgeMods }) }) - client.autoVersionHooks.push(function (response, client, options) { + client.autoVersionHooks.push(function (response, client) { if (!response.forgeData || response.forgeData.fmlNetworkVersion !== 2) { return // not ours } // Use the list of Forge mods from the server ping, so client will match server - const forgeMods = response.forgeData.mods + const forgeMods = response.forgeData.mods?.map(mod => mod.modId) console.log('Using forgeMods:', forgeMods) // Install the FML2 plugin with the given mods - forgeHandshake2(client, { forgeMods }) + forgeHandshake2(client, { ...forgeOptions, forgeMods: forgeOptions.forgeMods ?? forgeMods }) }) - client.autoVersionHooks.push(function (response, client, options) { - if (!response.forgeData || !response.forgeData.d) { + client.autoVersionHooks.push(function (response, client) { + if (!response.forgeData || response.forgeData.fmlNetworkVersion !== 3) { return // not ours } // Use the list of Forge mods from the server ping, so client will match server - const forgeMods = response.forgeData.mods - console.log('Using forgeMods:', forgeMods) + const advertisedMods = response.forgeData.mods + const forgeMods = forgeOptions.forgeMods ?? (advertisedMods?.length ? advertisedMods.map(mod => typeof mod === 'string' ? mod : mod.modId) : undefined) + console.log('Using forgeMods:', forgeMods || 'server handshake list') // Install the FML3 plugin with the given mods - forgeHandshake3(client, { forgeMods }) + forgeHandshake3(client, { ...forgeOptions, forgeMods }) + }) + + client.autoVersionHooks.push(function (response, client) { + if (response.forgeData?.fmlNetworkVersion !== 0) return + forgeHandshakeModern(client, forgeOptions) }) } diff --git a/src/client/commandRegistry.js b/src/client/commandRegistry.js new file mode 100644 index 0000000..5d1bab7 --- /dev/null +++ b/src/client/commandRegistry.js @@ -0,0 +1,80 @@ +const { ProtoDef } = require('protodef') +const minecraftData = require('minecraft-data') +const installedNodes = new WeakMap() + +const proto = new ProtoDef(false) +proto.addType('string', ['pstring', { countType: 'varint' }]) +const snapshot = require('./data/fml3.json').types.forge_snapshot +proto.addType('snapshot', ['container', snapshot[1].filter(field => field.name !== 'dummied')]) +proto.addType('dummied', ['array', { countType: 'varint', type: 'string' }]) + +function readRegistry (buffer) { + const parsed = proto.parsePacketBuffer('snapshot', buffer) + let size = parsed.metadata.size + // Older snapshots have a final dummied collection; newer ones omit it. + if (size < buffer.length) size += proto.read(buffer, size, 'dummied').size + if (size !== buffer.length) throw new Error('Unexpected trailing Forge registry data') + return parsed.data.ids +} + +function installCommandRegistry (client, buffer) { + return installCommandRegistryEntries(client, buffer && readRegistry(buffer)) +} + +function installCommandRegistryEntries (client, entries) { + const data = minecraftData(client.version) + const node = JSON.parse(JSON.stringify(data.protocol.types.command_node)) + const fields = node[1].find(field => field.name === 'extraNodeData').type[1].fields[2][1] + const parser = fields.find(field => field.name === 'parser') + // String-based versions need the Forge properties at initialization; numeric + // versions must wait for the server's ID registry before installing a schema. + const numeric = parser.type[0] === 'mapper' + if (parser.type !== 'string' && !numeric) throw new Error('Unsupported command argument schema') + if ((numeric && !entries) || (!numeric && entries)) return + + const properties = fields.find(field => field.name === 'properties').type[1].fields + const knownNames = new Set(numeric ? Object.values(parser.type[1].mappings) : Object.keys(properties)) + const forgeProperties = { + 'forge:enum': 'string', + 'forge:modid': 'void', + 'neoforge:enum': 'string', + 'neoforge:modid': 'void', + // minecraft-data calls this vanilla argument minecraft:nbt. + 'minecraft:nbt_compound_tag': properties['minecraft:nbt'], + 'minecraft:test_argument': 'void', + 'minecraft:test_class': 'void' + } + const mappings = {} + const names = new Set() + for (const { key, value } of numeric ? entries : []) { + if (!Number.isInteger(value) || value < 0 || Object.hasOwn(mappings, value) || names.has(key)) { + throw new Error('Duplicate or invalid Forge command argument registry entry') + } + if (!knownNames.has(key) && !Object.hasOwn(forgeProperties, key)) { + throw new Error(`Unsupported Forge command argument type: ${key}`) + } + mappings[value] = key + names.add(key) + } + if (numeric) parser.type[1].mappings = mappings + Object.assign(properties, forgeProperties) + + // This requires minecraft-protocol's isolated customPackets compilation. + // Never mutate the shared minecraft-data schema or a compiled protocol cache. + const version = data.version.majorVersion + const existing = client.customPackets?.[version] + const ownsExisting = existing?.types?.command_node === 'forge_command_node' && existing?.types?.forge_command_node === installedNodes.get(client) + if (!ownsExisting && (existing?.types?.command_node || existing?.types?.forge_command_node)) { + throw new Error('A custom command_node schema is already installed') + } + client.customPackets = { + ...client.customPackets, + [version]: { + ...existing, + types: { ...existing?.types, command_node: 'forge_command_node', forge_command_node: node } + } + } + installedNodes.set(client, node) +} + +module.exports = { readRegistry, installCommandRegistry, installCommandRegistryEntries } diff --git a/src/client/data/fabric.json b/src/client/data/fabric.json new file mode 100644 index 0000000..d12ca6a --- /dev/null +++ b/src/client/data/fabric.json @@ -0,0 +1,47 @@ +{ + "types": { + "string": [ + "pstring", + { + "countType": "varint" + } + ], + "versions": [ + "array", + { + "countType": "varint", + "type": "varint" + } + ], + "names": [ + "array", + { + "countType": "varint", + "type": "string" + } + ], + "registration": [ + "container", + [ + { + "name": "version", + "type": "varint" + }, + { + "name": "phase", + "type": "string" + }, + { + "name": "channels", + "type": [ + "array", + { + "countType": "varint", + "type": "string" + } + ] + } + ] + ] + } +} diff --git a/src/client/data/fabricRegistries.json b/src/client/data/fabricRegistries.json new file mode 100644 index 0000000..98c4bb8 --- /dev/null +++ b/src/client/data/fabricRegistries.json @@ -0,0 +1,93 @@ +{ + "types": { + "string": [ + "pstring", + { + "countType": "varint" + } + ], + "registrySync": [ + "array", + { + "countType": "varint", + "type": [ + "container", + [ + { + "name": "namespace", + "type": "string" + }, + { + "name": "registries", + "type": [ + "array", + { + "countType": "varint", + "type": [ + "container", + [ + { + "name": "path", + "type": "string" + }, + { + "name": "attributes", + "type": "u8" + }, + { + "name": "namespaces", + "type": [ + "array", + { + "countType": "varint", + "type": [ + "container", + [ + { + "name": "namespace", + "type": "string" + }, + { + "name": "bulks", + "type": [ + "array", + { + "countType": "varint", + "type": [ + "container", + [ + { + "name": "delta", + "type": "varint" + }, + { + "name": "paths", + "type": [ + "array", + { + "countType": "varint", + "type": "string" + } + ] + } + ] + ] + } + ] + } + ] + ] + } + ] + } + ] + ] + } + ] + } + ] + ] + } + ] + } +} diff --git a/src/client/data/fabricRegistryMappings.json b/src/client/data/fabricRegistryMappings.json new file mode 100644 index 0000000..22bb36d --- /dev/null +++ b/src/client/data/fabricRegistryMappings.json @@ -0,0 +1,10 @@ +{ + "1.21.11": { + "minecraft:particle_type": { + "aliases": { + "minecraft:trial_spawner_detection": "trial_spawner_detected_player", + "minecraft:trial_spawner_detection_ominous": "trial_spawner_detected_player_ominous" + } + } + } +} diff --git a/src/client/data/fml1.json b/src/client/data/fml1.json new file mode 100644 index 0000000..895c1f8 --- /dev/null +++ b/src/client/data/fml1.json @@ -0,0 +1,125 @@ +{ + "types": { + "string": [ + "pstring", + { + "countType": "varint" + } + ], + "fml|hsMapper": [ + "mapper", + { + "type": "i8", + "mappings": { + "0": "ServerHello", + "1": "ClientHello", + "2": "ModList", + "3": "RegistryData", + "-1": "HandshakeAck", + "-2": "HandshakeReset" + } + } + ], + "FML|HS": [ + "container", + [ + { + "name": "discriminator", + "type": "fml|hsMapper" + }, + { + "anon": true, + "type": [ + "switch", + { + "compareTo": "discriminator", + "fields": { + "ServerHello": [ + "container", + [ + { + "name": "fmlProtocolVersion", + "type": "i8" + }, + { + "name": "overrideDimension", + "type": [ + "switch", + { + "compareTo": "fmlProtocolVersion", + "fields": { + "0": "void", + "1": "void" + }, + "default": "i32" + } + ] + } + ] + ], + "ClientHello": [ + "container", + [ + { + "name": "fmlProtocolVersion", + "type": "i8" + } + ] + ], + "ModList": [ + "container", + [ + { + "name": "mods", + "type": [ + "array", + { + "countType": "varint", + "type": [ + "container", + [ + { + "name": "modid", + "type": "string" + }, + { + "name": "version", + "type": "string" + } + ] + ] + } + ] + } + ] + ], + "RegistryData": [ + "container", + [ + { + "name": "hasMore", + "type": "bool" + } + ] + ], + "HandshakeAck": [ + "container", + [ + { + "name": "phase", + "type": "i8" + } + ] + ], + "HandshakeReset": [ + "container", + [] + ] + } + } + ] + } + ] + ] + } +} diff --git a/src/client/data/fml3.json b/src/client/data/fml3.json index c347020..1c30267 100644 --- a/src/client/data/fml3.json +++ b/src/client/data/fml3.json @@ -200,21 +200,7 @@ }, { "name": "dataPackRegistries", - "type": [ - "array", - { - "countType": "varint", - "type": [ - "container", - [ - { - "name": "name", - "type": "string" - } - ] - ] - } - ] + "type": "optionalDataPackRegistries" } ] ], @@ -288,7 +274,7 @@ "name": "snapshot", "type": [ "option", - "forge_snapshot" + "restBuffer" ] } ] diff --git a/src/client/data/forgeModern.json b/src/client/data/forgeModern.json new file mode 100644 index 0000000..3735c55 --- /dev/null +++ b/src/client/data/forgeModern.json @@ -0,0 +1,146 @@ +{ + "types": { + "string": [ + "pstring", + { + "countType": "varint" + } + ], + "packet": [ + "container", + [ + { + "name": "id", + "type": "varint" + }, + { + "name": "data", + "type": [ + "switch", + { + "compareTo": "id", + "fields": { + "0": [ + "container", + [ + { + "name": "token", + "type": "varint" + } + ] + ], + "1": [ + "array", + { + "countType": "varint", + "type": [ + "container", + [ + { + "name": "id", + "type": "string" + }, + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + } + ] + ] + } + ], + "2": [ + "array", + { + "countType": "varint", + "type": [ + "container", + [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "varint" + } + ] + ] + } + ], + "3": [ + "container", + [ + { + "name": "token", + "type": "varint" + }, + { + "name": "normal", + "type": [ + "array", + { + "countType": "varint", + "type": "string" + } + ] + }, + { + "name": "datapacks", + "type": [ + "array", + { + "countType": "varint", + "type": "string" + } + ] + } + ] + ], + "4": [ + "container", + [ + { + "name": "token", + "type": "varint" + }, + { + "name": "name", + "type": "string" + }, + { + "name": "snapshot", + "type": "restBuffer" + } + ] + ], + "5": [ + "container", + [ + { + "name": "name", + "type": "string" + }, + { + "name": "data", + "type": [ + "buffer", + { + "countType": "varint" + } + ] + } + ] + ], + "6": "restBuffer" + } + } + ] + } + ] + ] + } +} diff --git a/src/client/data/neoforge.json b/src/client/data/neoforge.json new file mode 100644 index 0000000..1923051 --- /dev/null +++ b/src/client/data/neoforge.json @@ -0,0 +1,231 @@ +{ + "types": { + "string": [ + "pstring", + { + "countType": "varint" + } + ], + "query": [ + "array", + { + "countType": "varint", + "type": [ + "container", + [ + { + "name": "phase", + "type": "varint" + }, + { + "name": "channels", + "type": [ + "array", + { + "countType": "varint", + "type": [ + "container", + [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "flow", + "type": [ + "option", + "varint" + ] + }, + { + "name": "optional", + "type": "bool" + } + ] + ] + } + ] + } + ] + ] + } + ], + "setup": [ + "array", + { + "countType": "varint", + "type": [ + "container", + [ + { + "name": "phase", + "type": "varint" + }, + { + "name": "channels", + "type": [ + "array", + { + "countType": "varint", + "type": [ + "container", + [ + { + "name": "key", + "type": "string" + }, + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + } + ] + ] + } + ] + } + ] + ] + } + ], + "legacyQuery": [ + "container", + [ + { + "name": "configuration", + "type": [ + "array", + { + "countType": "varint", + "type": [ + "container", + [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": [ + "option", + "string" + ] + }, + { + "name": "flow", + "type": [ + "option", + "varint" + ] + }, + { + "name": "optional", + "type": "bool" + } + ] + ] + } + ] + }, + { + "name": "play", + "type": [ + "array", + { + "countType": "varint", + "type": [ + "container", + [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": [ + "option", + "string" + ] + }, + { + "name": "flow", + "type": [ + "option", + "varint" + ] + }, + { + "name": "optional", + "type": "bool" + } + ] + ] + } + ] + } + ] + ], + "legacySetup": [ + "container", + [ + { + "name": "configuration", + "type": [ + "array", + { + "countType": "varint", + "type": [ + "container", + [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": [ + "option", + "string" + ] + } + ] + ] + } + ] + }, + { + "name": "play", + "type": [ + "array", + { + "countType": "varint", + "type": [ + "container", + [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": [ + "option", + "string" + ] + } + ] + ] + } + ] + } + ] + ] + } +} diff --git a/src/client/data/neoforgeChecks.json b/src/client/data/neoforgeChecks.json new file mode 100644 index 0000000..1c63690 --- /dev/null +++ b/src/client/data/neoforgeChecks.json @@ -0,0 +1,65 @@ +{ + "types": { + "string": [ + "pstring", + { + "countType": "varint" + } + ], + "flags": [ + "array", + { + "countType": "varint", + "type": "string" + } + ], + "enums": [ + "array", + { + "countType": "varint", + "type": [ + "container", + [ + { + "name": "name", + "type": "string" + }, + { + "name": "direction", + "type": "string" + }, + { + "name": "extension", + "type": [ + "option", + [ + "container", + [ + { + "name": "vanillaCount", + "type": "varint" + }, + { + "name": "totalCount", + "type": "varint" + }, + { + "name": "entries", + "type": [ + "array", + { + "countType": "varint", + "type": "string" + } + ] + } + ] + ] + ] + } + ] + ] + } + ] + } +} diff --git a/src/client/data/neoforgeRegistries.json b/src/client/data/neoforgeRegistries.json new file mode 100644 index 0000000..71e6772 --- /dev/null +++ b/src/client/data/neoforgeRegistries.json @@ -0,0 +1,70 @@ +{ + "types": { + "string": [ + "pstring", + { + "countType": "varint" + } + ], + "names": [ + "array", + { + "countType": "varint", + "type": "string" + } + ], + "registry": [ + "container", + [ + { + "name": "name", + "type": "string" + }, + { + "name": "ids", + "type": [ + "array", + { + "countType": "varint", + "type": [ + "container", + [ + { + "name": "value", + "type": "varint" + }, + { + "name": "key", + "type": "string" + } + ] + ] + } + ] + }, + { + "name": "aliases", + "type": [ + "array", + { + "countType": "varint", + "type": [ + "container", + [ + { + "name": "key", + "type": "string" + }, + { + "name": "value", + "type": "string" + } + ] + ] + } + ] + } + ] + ] + } +} diff --git a/src/client/fabricNetworking.js b/src/client/fabricNetworking.js new file mode 100644 index 0000000..0d67a69 --- /dev/null +++ b/src/client/fabricNetworking.js @@ -0,0 +1,129 @@ +const { ProtoDef } = require('protodef') +const decodeRegistries = require('./fabricRegistries') + +const proto = new ProtoDef(false) +proto.addTypes(require('./data/fabric.json').types) + +function decode (type, buffer) { + const parsed = proto.parsePacketBuffer(type, buffer) + if (parsed.metadata.size !== buffer.length) throw new Error(`Trailing Fabric ${type} data`) + return parsed.data +} + +// Fabric API common networking, version 1. This is not a mod implementation: +// callers explicitly register their configuration/play payload handlers. +module.exports = function (client, options = {}) { + let registryReceived = false + const handlers = { + configuration: { ...options.configurationHandlers }, + play: { ...options.playHandlers } + } + if (options.registryHandler !== undefined) { + if (client.version !== '1.21.11') throw new Error('Fabric registry synchronization is only verified for 1.21.11') + if (typeof options.registryHandler !== 'function') throw new TypeError('Fabric registryHandler must be a function') + if (Object.hasOwn(handlers.configuration, 'fabric:registry/sync')) throw new Error('Duplicate Fabric registry handler') + handlers.configuration['fabric:registry/sync'] = bytes => { + if (registryReceived) throw new Error('Duplicate Fabric registry synchronization') + const registries = decodeRegistries(bytes) + registryReceived = true + if (options.registryHandler(registries, { client }) !== true) { + const error = new Error('Fabric registry handler did not confirm applied mappings') + error.code = 'FABRIC_REGISTRY_NOT_APPLIED' + throw error + } + client.write('custom_payload', { channel: 'fabric:registry/sync/complete', data: Buffer.alloc(0) }) + client.emit('fabricRegistries', registries) + } + } + const channels = Object.fromEntries(Object.entries(handlers).map(([phase, entries]) => { + for (const [name, handler] of Object.entries(entries)) { + if (!/^[a-z0-9_.-]+:[a-z0-9/._-]+$/.test(name) || typeof handler !== 'function') throw new TypeError('Invalid Fabric channel handler') + if (['c:version', 'c:register', 'minecraft:register', 'minecraft:unregister'].includes(name)) throw new Error('Reserved Fabric channel') + } + return [phase, ['c:version', 'c:register', ...Object.keys(entries)]] + })) + const remoteChannels = { configuration: new Set(), play: new Set() } + let negotiatedVersion + const registered = new Set() + let failed = false + const write = (channel, type, data) => client.write('custom_payload', { channel, data: proto.createPacketBuffer(type, data) }) + const earlyRegistration = 'fabric-networking-api-v1:early_registration' + const loginHandlers = { ...options.loginHandlers } + for (const handler of Object.values(loginHandlers)) { + if (typeof handler !== 'function') throw new TypeError('Fabric login handlers must be functions') + } + if (Object.hasOwn(loginHandlers, earlyRegistration)) throw new Error('Reserved Fabric early registration channel') + const defaultLogin = client.listeners('login_plugin_request').find(listener => listener.name === 'onLoginPluginRequest') + if (defaultLogin) client.removeListener('login_plugin_request', defaultLogin) + client.on('login_plugin_request', async packet => { + if (failed) return + try { + let response + if (packet.channel === earlyRegistration) { + for (const name of decode('names', packet.data)) remoteChannels.play.add(name) + response = proto.createPacketBuffer('names', Object.keys(handlers.play)) + } else if (Object.hasOwn(loginHandlers, packet.channel)) { + response = await loginHandlers[packet.channel](packet.data, { client, messageId: packet.messageId }) + if (response !== undefined && !Buffer.isBuffer(response)) throw new TypeError('Fabric login handler must return a Buffer or undefined') + } else if (defaultLogin) { + defaultLogin.call(client, packet) + return + } + if (!failed) client.write('login_plugin_response', { messageId: packet.messageId, data: response }) + } catch (error) { + failed = true + client.emit('error', error) + } + }) + + client.on('state', state => { + if (state === 'configuration') { + registryReceived = false + negotiatedVersion = undefined + registered.clear() + remoteChannels.configuration.clear() + remoteChannels.play.clear() + } + }) + + client.on('custom_payload', packet => { + const phase = client.state + if (failed || !Object.hasOwn(handlers, phase)) return + try { + if (packet.channel === 'minecraft:register' || packet.channel === 'minecraft:unregister') { + // Accept raw registration bytes and arrays decoded by minecraft-protocol. + const names = Array.isArray(packet.data) ? packet.data : packet.data.toString('utf8').split('\0').filter(Boolean) + for (const name of names) { + if (packet.channel === 'minecraft:register') remoteChannels[phase].add(name) + else remoteChannels[phase].delete(name) + } + if (!registered.has(phase) && packet.channel === 'minecraft:register') { + registered.add(phase) + client.write('custom_payload', { channel: 'minecraft:register', data: Buffer.from(channels[phase].join('\0')) }) + } + return + } + if (packet.channel === 'c:version') { + const versions = decode('versions', packet.data) + if (!versions.includes(1)) throw new Error('Unsupported Fabric common networking version') + negotiatedVersion = 1 + write('c:version', 'versions', [1]) + return + } + if (packet.channel === 'c:register') { + const registration = decode('registration', packet.data) + if (negotiatedVersion !== 1 || registration.version !== negotiatedVersion) throw new Error('Fabric registration before matching version negotiation') + if (!Object.hasOwn(channels, registration.phase)) throw new Error('Unsupported Fabric registration phase') + for (const name of registration.channels) remoteChannels[registration.phase].add(name) + write('c:register', 'registration', { version: 1, phase: registration.phase, channels: channels[registration.phase] }) + client.emit('fabricChannels', registration.phase, [...remoteChannels[registration.phase]]) + return + } + if (Object.hasOwn(handlers[phase], packet.channel)) handlers[phase][packet.channel](packet.data, { client, phase }) + } catch (error) { + failed = true + client.emit('error', error) + } + }) + return { remoteChannels } +} diff --git a/src/client/fabricRegistries.js b/src/client/fabricRegistries.js new file mode 100644 index 0000000..3b90d11 --- /dev/null +++ b/src/client/fabricRegistries.js @@ -0,0 +1,39 @@ +const { ProtoDef } = require('protodef') +const proto = new ProtoDef(false) +proto.addTypes(require('./data/fabricRegistries.json').types) + +module.exports = function decodeRegistries (bytes) { + const parsed = proto.parsePacketBuffer('registrySync', bytes) + if (parsed.metadata.size !== bytes.length) throw new Error('Trailing Fabric registry data') + const registries = new Map() + const identifier = (namespace, path) => { + const name = `${namespace || 'minecraft'}:${path}` + if (!/^[a-z0-9_.-]+:[a-z0-9/._-]+$/.test(name)) throw new Error('Invalid Fabric registry identifier') + return name + } + for (const group of parsed.data) { + for (const registry of group.registries) { + const name = identifier(group.namespace, registry.path) + if (registries.has(name)) throw new Error('Duplicate Fabric registry') + if (registry.attributes & ~1) throw new Error('Unsupported Fabric registry attributes') + const entries = new Map() + const ids = new Set() + let last = 0 + for (const namespace of registry.namespaces) { + for (const bulk of namespace.bulks) { + if (!bulk.paths.length) throw new Error('Empty Fabric registry ID bulk') + let id = last + bulk.delta + for (const path of bulk.paths) { + const key = identifier(namespace.namespace, path) + if (!Number.isSafeInteger(id) || id < 0 || id > 0x7fffffff || ids.has(id) || entries.has(key)) throw new Error('Duplicate or invalid Fabric registry entry') + entries.set(key, id) + ids.add(id++) + } + last = id - 1 + } + } + registries.set(name, { optional: Boolean(registry.attributes & 1), entries }) + } + } + return registries +} diff --git a/src/client/fabricRegistryMappings.js b/src/client/fabricRegistryMappings.js new file mode 100644 index 0000000..90dfea7 --- /dev/null +++ b/src/client/fabricRegistryMappings.js @@ -0,0 +1,131 @@ +const minecraftData = require('minecraft-data') + +const installed = new WeakMap() +const versionMappings = require('./data/fabricRegistryMappings.json') +const supported = new Set([ + 'minecraft:data_component_type', + 'minecraft:particle_type', + 'minecraft:sound_event' +]) + +function clone (value) { + return JSON.parse(JSON.stringify(value)) +} + +function registrySchema (data, registryName, registry, typeName, codecTable, aliases) { + const type = clone(data.protocol.types[typeName]) + const mapper = typeName === 'Particle' + ? type[1].find(field => field.name === 'type').type[1].mappings + : type[1].mappings + const vanillaNames = new Set(Object.values(mapper)) + const liveNames = new Set(registry.entries.keys()) + const mappings = {} + const codecs = {} + + for (const [name, id] of registry.entries) { + const separator = name.indexOf(':') + const namespace = name.slice(0, separator) + const path = name.slice(separator + 1) + const wireName = namespace === 'minecraft' ? (aliases[name] || path) : name + if (namespace === 'minecraft') { + if (!vanillaNames.has(wireName)) throw new Error(`Unsupported vanilla ${registryName} entry: ${name}`) + } else { + if (!Object.hasOwn(codecTable, name)) throw new Error(`Missing ${registryName} codec: ${name}`) + codecs[wireName] = clone(codecTable[name]) + } + if (Object.hasOwn(mappings, id)) throw new Error(`Duplicate ${registryName} ID: ${id}`) + mappings[id] = wireName + } + + for (const vanilla of vanillaNames) { + const original = Object.entries(aliases).find(([, mapped]) => mapped === vanilla)?.[0] || `minecraft:${vanilla}` + if (!liveNames.has(original)) { + throw new Error(`Missing vanilla ${registryName} entry: ${original}`) + } + } + if (typeName === 'Particle') { + type[1].find(field => field.name === 'type').type[1].mappings = mappings + Object.assign(type[1].find(field => field.name === 'data').type[1].fields, codecs) + } else { + type[1].mappings = mappings + } + return { type, codecs } +} + +function installFabricRegistryMappings (client, registries, options = {}) { + if (client.version !== '1.21.11') throw new Error('Fabric registry mappings are only verified for 1.21.11') + if (!(registries instanceof Map)) throw new TypeError('Fabric registries must be a Map') + for (const name of registries.keys()) { + if (!supported.has(name)) throw new Error(`Unsupported Fabric registry: ${name}`) + } + for (const [name, registry] of registries) { + if (!(registry.entries instanceof Map)) throw new TypeError(`Invalid Fabric registry entries: ${name}`) + } + + const data = minecraftData(client.version) + const mappings = versionMappings[client.version] + const particleName = 'minecraft:particle_type' + const componentName = 'minecraft:data_component_type' + const particle = registries.has(particleName) + ? registrySchema(data, particleName, registries.get(particleName), 'Particle', options.particleTypes || {}, mappings[particleName]?.aliases || {}) + : null + const componentType = registries.has(componentName) + ? registrySchema(data, componentName, registries.get(componentName), 'SlotComponentType', options.dataComponentTypes || {}, mappings[componentName]?.aliases || {}) + : null + let component + if (componentType) { + component = clone(data.protocol.types.SlotComponent) + const componentFields = component[1].find(field => field.name === 'data').type[1].fields + Object.assign(componentFields, componentType.codecs) + component[1].find(field => field.name === 'type').type = 'fabric_SlotComponentType' + } + const version = data.version.majorVersion + const existing = client.customPackets?.[version] + const previous = installed.get(client) + const ownedEntries = previous + ? { + Particle: 'fabric_Particle', + fabric_Particle: previous.particle, + SlotComponentType: 'fabric_SlotComponentType', + fabric_SlotComponentType: previous.componentType, + SlotComponent: 'fabric_SlotComponent', + fabric_SlotComponent: previous.component + } + : {} + const names = particle ? ['Particle', 'fabric_Particle'] : [] + if (componentType) names.push('SlotComponentType', 'fabric_SlotComponentType', 'SlotComponent', 'fabric_SlotComponent') + for (const name of names) { + if (existing?.types?.[name] !== undefined && existing.types[name] !== ownedEntries[name]) throw new Error(`A custom ${name} schema is already installed`) + } + + const types = { ...existing?.types } + for (const [name, value] of Object.entries(ownedEntries)) { + if (types[name] === value) delete types[name] + } + const owned = { + particle: particle?.type, + componentType: componentType?.type, + component + } + if (particle) Object.assign(types, { Particle: 'fabric_Particle', fabric_Particle: owned.particle }) + if (componentType) { + Object.assign(types, { + SlotComponentType: 'fabric_SlotComponentType', + fabric_SlotComponentType: owned.componentType, + SlotComponent: 'fabric_SlotComponent', + fabric_SlotComponent: owned.component + }) + } + client.customPackets = { + ...client.customPackets, + [version]: { + ...existing, + types + } + } + installed.set(client, owned) + client.fabricRegistries = registries + return true +} + +module.exports = installFabricRegistryMappings diff --git a/src/client/forgeHandshake.js b/src/client/forgeHandshake.js index c7a90ab..dac4aeb 100644 --- a/src/client/forgeHandshake.js +++ b/src/client/forgeHandshake.js @@ -3,152 +3,8 @@ const assert = require('assert') const debug = require('../../debug') const proto = new ProtoDef() -// copied from ../../dist/transforms/serializer.js TODO: refactor -proto.addType('string', [ - 'pstring', - { - countType: 'varint' - } -]) - -// http://wiki.vg/Minecraft_Forge_Handshake -// TODO: move to https://github.com/PrismarineJS/minecraft-data -proto.addType('fml|hsMapper', [ - 'mapper', - { - type: 'i8', - mappings: { - 0: 'ServerHello', - 1: 'ClientHello', - 2: 'ModList', - 3: 'RegistryData', - '-1': 'HandshakeAck', - '-2': 'HandshakeReset' - } - } -]) - -proto.addType('FML|HS', [ - 'container', - [ - { - name: 'discriminator', - type: 'fml|hsMapper' - }, - - { - anon: true, - type: [ - 'switch', - { - compareTo: 'discriminator', - fields: { - ServerHello: [ - 'container', - [ - { - name: 'fmlProtocolVersion', - type: 'i8' - }, - { - name: 'overrideDimension', - type: [ - 'switch', - { - // "Only sent if protocol version is greater than 1." - compareTo: 'fmlProtocolVersion', - fields: { - 0: 'void', - 1: 'void' - }, - default: 'i32' - } - ] - } - ] - ], - - ClientHello: [ - 'container', - [ - { - name: 'fmlProtocolVersion', - type: 'i8' - } - ] - ], - - ModList: [ - 'container', - [ - { - name: 'mods', - type: [ - 'array', - { - countType: 'varint', - type: [ - 'container', - [ - { - name: 'modid', - type: 'string' - }, - { - name: 'version', - type: 'string' - } - ] - ] - } - ] - } - ] - ], - - RegistryData: [ - 'container', - [ - { - name: 'hasMore', - type: 'bool' - } - - /* TODO: support all fields http://wiki.vg/Minecraft_Forge_Handshake#RegistryData - * TODO: but also consider http://wiki.vg/Minecraft_Forge_Handshake#ModIdData - * and https://github.com/ORelio/Minecraft-Console-Client/pull/100/files#diff-65b97c02a9736311374109e22d30ca9cR297 - { - "name": "registryName", - "type": "string" - }, - */ - ] - ], - - HandshakeAck: [ - 'container', - [ - { - name: 'phase', - type: 'i8' - } - ] - ], - HandshakeReset: [ - 'container', - [ - { - name: 'phase', - type: 'i8' - } - ] - ] - } - } - ] - } - ] -]) +// RegistryData currently decodes only its sequencing prefix, not FML1 mappings. +proto.addTypes(require('./data/fml1.json').types) function writeAck (client, phase) { const ackData = proto.createPacketBuffer('FML|HS', { @@ -174,8 +30,9 @@ function fmlHandshakeStep (client, data, options) { const parsed = proto.parsePacketBuffer('FML|HS', data) debug('FML|HS', parsed) - const fmlHandshakeState = - client.fmlHandshakeState || FMLHandshakeClientState.RESET + const fmlHandshakeState = parsed.data.discriminator === 'HandshakeReset' + ? FMLHandshakeClientState.RESET + : (client.fmlHandshakeState || FMLHandshakeClientState.START) switch (fmlHandshakeState) { case FMLHandshakeClientState.START: { @@ -227,13 +84,7 @@ function fmlHandshakeStep (client, data, options) { // Emit event so client can check client/server mod compatibility client.emit('forgeMods', parsed.data.mods) - if (client.fmlHandshakeReset) { - writeAck(client, FMLHandshakeClientState.PENDINGCOMPLETE) - client.fmlHandshakeState = FMLHandshakeClientState.PENDINGCOMPLETE - } else { - client.fmlHandshakeState = - FMLHandshakeClientState.WAITINGSERVERCOMPLETE - } + client.fmlHandshakeState = FMLHandshakeClientState.WAITINGSERVERCOMPLETE break } @@ -243,7 +94,6 @@ function fmlHandshakeStep (client, data, options) { `expected RegistryData in WAITINGSERVERCOMPLETE, got ${parsed.data.discriminator}` ) debug('RegistryData', parsed.data) - console.log('RegistryData', parsed) if ( client.version === '1.7.10' || // actually ModIdData packet, and there is only one of those TODO: avoid hardcoding version, allow earlier parsed.data.hasMore === false @@ -288,9 +138,7 @@ function fmlHandshakeStep (client, data, options) { `expected HandshakeReset in RESET state, got ${parsed.data.discriminator}` ) - writeAck(client, FMLHandshakeClientState.START) client.fmlHandshakeState = FMLHandshakeClientState.START - client.fmlHandshakeReset = true debug('HandshakeReset!') break } diff --git a/src/client/forgeHandshake2.js b/src/client/forgeHandshake2.js index 6c3bad2..6b6f5c8 100644 --- a/src/client/forgeHandshake2.js +++ b/src/client/forgeHandshake2.js @@ -1,5 +1,6 @@ const ProtoDef = require('protodef').ProtoDef const debug = require('debug')('minecraft-protocol-forge') +const { installCommandRegistry } = require('./commandRegistry') // Channels const FML_CHANNELS = { @@ -55,6 +56,7 @@ proto.addProtocol(require('./data/fml2.json'), ['fml2']) * }} options */ module.exports = function (client, options) { + if (client.version) installCommandRegistry(client) const modNames = options.forgeMods const channels = options.channels const registries = options.registries @@ -120,7 +122,7 @@ module.exports = function (client, options) { registries: [] } - if (!options.modNames) { + if (!modNames) { modlistreply.modNames = modlist.modNames } diff --git a/src/client/forgeHandshake3.js b/src/client/forgeHandshake3.js index c8ae4ba..315ee45 100644 --- a/src/client/forgeHandshake3.js +++ b/src/client/forgeHandshake3.js @@ -1,5 +1,6 @@ const ProtoDef = require('protodef').ProtoDef const debug = require('debug')('minecraft-protocol-forge') +const { installCommandRegistry } = require('./commandRegistry') // Channels const FML_CHANNELS = { @@ -44,6 +45,20 @@ proto.addTypes({ proto.addProtocol(require('./data/fml3.json'), ['fml3']) +// Forge 1.18.2 omits an empty data-pack registry list at the end of ModList. +// Later versions always send the count, including when it is zero. +const dataPackRegistryList = ['array', { + countType: 'varint', + type: ['container', [{ name: 'name', type: 'string' }]] +}] +proto.addType('optionalDataPackRegistries', [ + (buffer, offset) => offset === buffer.length + ? { value: [], size: 0 } + : proto.read(buffer, offset, dataPackRegistryList), + (value, buffer, offset) => proto.write(value, buffer, offset, dataPackRegistryList), + value => proto.sizeOf(value, dataPackRegistryList) +]) + /** * FML3 handshake to the server. * ! There is no wiki for it. @@ -51,13 +66,16 @@ proto.addProtocol(require('./data/fml3.json'), ['fml3']) * @param {{ * forgeMods: Array. | undefined, * channels: Object. | undefined, - * registries: Object. | undefined + * registries: Object. | undefined, + * loginHandlers: Object. | undefined * }} options */ -module.exports = function (client, options) { +module.exports = function (client, options = {}) { + if (client.version) installCommandRegistry(client) const modNames = options.forgeMods const channels = options.channels const registries = options.registries + const loginHandlers = options.loginHandlers || {} // passed to src/client/setProtocol.js, signifies client supports FML2/Forge client.tagHost = '\0FML3\0' @@ -87,7 +105,7 @@ module.exports = function (client, options) { // remove default login_plugin_request listener which would answer with an empty packet // and make the server disconnect us const nmplistener = client.listeners('login_plugin_request').find((fn) => fn.name === 'onLoginPluginRequest') - client.removeListener('login_plugin_request', nmplistener) + if (nmplistener) client.removeListener('login_plugin_request', nmplistener) client.on('login_plugin_request', (data) => { if (data.channel === 'fml:loginwrapper') { @@ -120,7 +138,7 @@ module.exports = function (client, options) { registries: [] } - if (!options.modNames) { + if (!modNames) { modlistreply.modNames = modlist.modNames } @@ -176,6 +194,9 @@ module.exports = function (client, options) { // respond with Ack case 'ServerRegistry': { + if (handshake.data.name === 'minecraft:command_argument_type' && handshake.data.snapshot) { + installCommandRegistry(client, handshake.data.snapshot) + } loginwrapperpacket = proto.createPacketBuffer( PROTODEF_TYPES.LOGINWRAPPER, { @@ -204,19 +225,9 @@ module.exports = function (client, options) { break } - // respond with Ack + // Forge marks ModData as noResponse(), so it must not be acknowledged. case 'ModData': { - loginwrapperpacket = proto.createPacketBuffer( - PROTODEF_TYPES.LOGINWRAPPER, - { - channel: FML_CHANNELS.HANDSHAKE, - data: proto.createPacketBuffer(PROTODEF_TYPES.HANDSHAKE, { - discriminator: 'Acknowledgement', - data: {} - }) - } - ) - break + return } // respond with Ack ? @@ -246,17 +257,28 @@ module.exports = function (client, options) { break } - default: - try { - console.log('other loginwrapperchannel', loginwrapper.channel, 'received, sending acknowledgement packet') - const AcknowledgementPacket = proto.createPacketBuffer(PROTODEF_TYPES.HANDSHAKE, { discriminator: 'Acknowledgement' }) - const loginWrapperPacket = proto.createPacketBuffer(PROTODEF_TYPES.LOGINWRAPPER, { channel: FML_CHANNELS.HANDSHAKE, data: AcknowledgementPacket }) - client.write('login_plugin_response', { messageId: data.messageId, data: loginWrapperPacket }) + default: { + client.emit('forgeLoginPluginRequest', { + messageId: data.messageId, + channel: loginwrapper.channel, + data: loginwrapper.data + }) + if (!loginHandlers[loginwrapper.channel]) { + client.write('login_plugin_response', { messageId: data.messageId }) break - } catch (error) { - console.error(error) } + const response = loginHandlers[loginwrapper.channel](loginwrapper.data, data) + if (response !== undefined && !Buffer.isBuffer(response)) { + throw new TypeError(`Login handler for ${loginwrapper.channel} must return a Buffer or undefined`) + } + client.write('login_plugin_response', { + messageId: data.messageId, + data: response === undefined + ? undefined + : proto.createPacketBuffer(PROTODEF_TYPES.LOGINWRAPPER, { channel: loginwrapper.channel, data: response }) + }) break + } } } else { console.log('other channel', data.channel, 'received') diff --git a/src/client/forgeHandshakeModern.js b/src/client/forgeHandshakeModern.js new file mode 100644 index 0000000..bfd7b54 --- /dev/null +++ b/src/client/forgeHandshakeModern.js @@ -0,0 +1,101 @@ +const { ProtoDef } = require('protodef') +const { readRegistry, installCommandRegistry } = require('./commandRegistry') + +const proto = new ProtoDef(false) +proto.addType('restBuffer', [ + (buffer, offset) => ({ value: buffer.subarray(offset), size: buffer.length - offset }), + (value, buffer, offset) => { value.copy(buffer, offset); return offset + value.length }, + value => value.length +]) +proto.addTypes(require('./data/forgeModern.json').types) + +// Forge's post-FML3 configuration protocol, advertised as network version 0. +module.exports = function (client, options = {}) { + client.tagHost = '\0FORGE\0' + let receivedMods = false + let receivedChannels = false + let failed = false + let pendingRegistries + client.forgeRegistries = new Map() + client.forgeHandshakeComplete = false + const channels = options.channels || { 'forge:handshake': 0, 'forge:login': 0 } + const send = (id, data) => client.write('custom_payload', { + channel: 'forge:handshake', data: proto.createPacketBuffer('packet', { id, data }) + }) + const ack = token => send(0, { token }) + + client.on('custom_payload', packet => { + if (failed || client.state !== 'configuration') return + if (packet.channel === 'minecraft:register') { + client.write('custom_payload', { + channel: 'minecraft:register', data: Buffer.from(Object.keys(channels).join('\0') + '\0') + }) + return + } + if (packet.channel !== 'forge:handshake') return + try { + const parsed = proto.parsePacketBuffer('packet', packet.data) + if (parsed.metadata.size !== packet.data.length) throw new Error('Incomplete modern Forge packet decode') + const { id, data } = parsed.data + switch (id) { + case 1: + receivedMods = true + client.emit('forgeMods', data) + send(1, options.modVersions || data) + break + case 2: + receivedChannels = true + client.emit('forgeChannels', data) + send(2, Object.entries(channels).map(([name, version]) => ({ name, version }))) + break + case 3: + if (pendingRegistries) throw new Error('Duplicate modern Forge registry list') + if (data.datapacks.some(name => !(options.dataPackRegistries || []).includes(name))) { + throw new Error('Unsupported modern Forge data-pack registry') + } + pendingRegistries = new Set(data.normal) + if (pendingRegistries.size !== data.normal.length) throw new Error('Duplicate modern Forge registry name') + ack(data.token) + break + case 4: + if (!pendingRegistries?.has(data.name)) throw new Error(`Unexpected modern Forge registry: ${data.name}`) + client.forgeRegistries.set(data.name, readRegistry(data.snapshot)) + if (data.name === 'minecraft:command_argument_type') installCommandRegistry(client, data.snapshot) + pendingRegistries.delete(data.name) + client.emit('forgeRegistry', data.name, client.forgeRegistries.get(data.name)) + ack(data.token) + break + case 5: + client.emit('forgeConfig', data.name, data.data) + break // ConfigData has no acknowledgment. + case 6: { + const error = new Error('Modern Forge rejected channel negotiation') + error.code = 'FORGE_CHANNEL_MISMATCH' + throw error + } + default: + throw new Error(`Unsupported modern Forge handshake packet: ${id}`) + } + } catch (error) { + failed = true + client.emit('error', error) + } + }) + client.on('state', state => { + if (!failed && state === 'configuration' && client.forgeHandshakeComplete) { + receivedMods = false + receivedChannels = false + pendingRegistries = undefined + client.forgeRegistries.clear() + client.forgeHandshakeComplete = false + return + } + if (failed || state !== 'play') return + if (!receivedMods || !receivedChannels || !pendingRegistries || pendingRegistries.size) { + failed = true + client.emit('error', new Error('Modern Forge entered play before completing configuration')) + return + } + client.forgeHandshakeComplete = true + }) +} diff --git a/src/client/neoforgeChecks.js b/src/client/neoforgeChecks.js new file mode 100644 index 0000000..2d07f5d --- /dev/null +++ b/src/client/neoforgeChecks.js @@ -0,0 +1,40 @@ +const { ProtoDef } = require('protodef') +const proto = new ProtoDef(false) +proto.addTypes(require('./data/neoforgeChecks.json').types) + +module.exports = function (client) { + const sendAck = name => client.write('custom_payload', { channel: `neoforge:${name}`, data: Buffer.alloc(0) }) + const decode = (type, bytes) => { + const result = proto.parsePacketBuffer(type, bytes) + if (result.metadata.size !== bytes.length) throw new Error('Trailing NeoForge compatibility data') + return result.data + } + return { + 'neoforge:extensible_enum_data': { + version: '1', + flow: 'clientbound', + optional: true, + handler: bytes => { + const entries = decode('enums', bytes) + if (entries.some(entry => entry.extension || !['CLIENTBOUND', 'SERVERBOUND', 'BIDIRECTIONAL'].includes(entry.direction))) { + throw new Error('Unsupported NeoForge enum extensions') + } + client.emit('neoforgeEnums', entries) + sendAck('extensible_enum_ack') + } + }, + 'neoforge:extensible_enum_ack': { version: '1', flow: 'serverbound', optional: true }, + 'neoforge:feature_flags': { + version: '1', + flow: 'clientbound', + optional: true, + handler: bytes => { + const flags = decode('flags', bytes) + if (flags.length) throw new Error('Unsupported NeoForge modded feature flags') + client.emit('neoforgeFeatureFlags', flags) + sendAck('feature_flags_ack') + } + }, + 'neoforge:feature_flags_ack': { version: '1', flow: 'serverbound', optional: true } + } +} diff --git a/src/client/neoforgeHandshake.js b/src/client/neoforgeHandshake.js new file mode 100644 index 0000000..f08fefd --- /dev/null +++ b/src/client/neoforgeHandshake.js @@ -0,0 +1,134 @@ +const { ProtoDef } = require('protodef') +const compatibilityChecks = require('./neoforgeChecks') +const registryHandlers = require('./neoforgeRegistries') + +const proto = new ProtoDef(false) +proto.addTypes(require('./data/neoforge.json').types) + +const phases = { configuration: 4, play: 1 } +const builtins = ['neoforge:register', 'neoforge:network', 'neoforge:modded_network_setup_failed'] + +function decode (type, buffer) { + const result = proto.parsePacketBuffer(type, buffer) + if (result.metadata.size !== buffer.length) throw new Error(`Trailing NeoForge ${type} data`) + return result.data +} + +// NeoForge channel negotiation. Mod registry/config/gameplay handlers +// must be supplied explicitly; server channels are never blindly reflected. +module.exports = function (client, options = {}) { + if (!['1.20.4', '1.21.1', '1.21.11'].includes(client.version)) throw new Error('Unsupported NeoForge Minecraft version') + const legacy = client.version === '1.20.4' + const registryChannels = registryHandlers(client) + if (legacy) { + // Verified against the 20.4.251 universal JAR's Specification-Version. + for (const channel of Object.values(registryChannels)) channel.version = '20.4' + } + const playChannels = {} + if (client.version === '1.21.11') { + playChannels['neoforge:recipe_content'] = { + version: '1', + flow: 'clientbound', + optional: true, + handler: bytes => { + // NeoForge sends two empty collections when no mod requests recipe sync. + // Nonempty collections need registry-aware recipe serializers. + if (!bytes.equals(Buffer.from([0, 0]))) throw new Error('Unsupported NeoForge recipe content; supply a recipe handler') + client.emit('neoforgeRecipes', { recipeTypes: [], recipes: [] }) + } + } + } + const registrations = { + configuration: { ...(legacy ? {} : compatibilityChecks(client)), ...registryChannels, ...options.configurationChannels }, + play: { ...playChannels, ...options.playChannels } + } + const query = Object.entries(registrations).map(([phase, entries]) => ({ + phase: phases[phase], + channels: Object.entries(entries).map(([name, entry]) => { + if (!/^[a-z0-9_.-]+:[a-z0-9/._-]+$/.test(name) || builtins.includes(name)) throw new Error('Invalid NeoForge channel name') + if (typeof entry.version !== 'string' || !entry.version.length) throw new TypeError('NeoForge channel version must be a nonempty string') + if (entry.flow !== undefined && !['clientbound', 'serverbound'].includes(entry.flow)) throw new Error('Invalid NeoForge channel flow') + if (entry.flow !== 'serverbound' && typeof entry.handler !== 'function') throw new TypeError('Incoming NeoForge channels require a handler') + return { name, version: entry.version, flow: entry.flow === undefined ? undefined : entry.flow === 'serverbound' ? 0 : 1, optional: entry.optional === true } + }) + })) + let queried = false + let setup + let failed = false + client.neoforgeHandshakeComplete = false + client.on('state', state => { + if (failed) return + if (state === 'configuration') { + queried = false + setup = undefined + client.neoforgeHandshakeComplete = false + } else if (state === 'play') { + if (!setup || (setup.get('configuration')?.has('neoforge:frozen_registry_sync_completed') && !client.neoforgeRegistrySyncComplete)) { + failed = true + client.emit('error', new Error('NeoForge entered play without channel negotiation or registry completion')) + } else client.neoforgeHandshakeComplete = true + } + }) + // The current minecraft-protocol dependency does not answer configuration + // pings. NeoForge waits for pong(0) before it starts configuration tasks. + // Disable when an application already handles these vanilla packets. + if (options.respondToPing !== false) { + client.on('ping', packet => { + if (!failed && client.state === 'configuration') client.write('pong', packet) + }) + } + client.on('custom_payload', packet => { + if (failed || !Object.hasOwn(phases, client.state)) return + try { + if (packet.channel === 'neoforge:modded_network_setup_failed') { + const error = new Error('NeoForge rejected channel negotiation') + error.code = 'NEOFORGE_CHANNEL_MISMATCH' + throw error + } + if (packet.channel === 'neoforge:register' && client.state === 'configuration') { + const incoming = decode(legacy ? 'legacyQuery' : 'query', packet.data) + if (queried || (legacy ? incoming.configuration.length + incoming.play.length : incoming.length) !== 0) throw new Error('Unexpected NeoForge network query') + queried = true + const reply = legacy ? { configuration: query[0].channels, play: query[1].channels } : query + client.write('custom_payload', { channel: packet.channel, data: proto.createPacketBuffer(legacy ? 'legacyQuery' : 'query', reply) }) + return + } + if (packet.channel === 'neoforge:network' && client.state === 'configuration') { + if (!queried || setup) throw new Error('Unexpected NeoForge network setup') + const incoming = decode(legacy ? 'legacySetup' : 'setup', packet.data) + const decoded = legacy + ? Object.entries(incoming).map(([phase, channels]) => ({ phase: phases[phase], channels: channels.map(channel => ({ ...channel, key: channel.name })) })) + : incoming + const selected = new Map() + for (const group of decoded) { + const phase = Object.keys(phases).find(name => phases[name] === group.phase) + if (!phase || selected.has(phase)) throw new Error('Invalid NeoForge setup phase') + const names = new Set() + for (const channel of group.channels) { + const offered = registrations[phase][channel.name] + if (channel.key !== channel.name || names.has(channel.name) || !offered || offered.version !== channel.version) throw new Error('Unoffered or mismatched NeoForge channel') + names.add(channel.name) + } + selected.set(phase, names) + } + for (const [phase, entries] of Object.entries(registrations)) { + for (const [name, entry] of Object.entries(entries)) { + if (entry.optional !== true && !selected.get(phase)?.has(name)) throw new Error('Missing required NeoForge channel in setup') + } + } + setup = selected + client.write('custom_payload', { channel: 'minecraft:register', data: Buffer.from([...builtins, ...(setup.get('configuration') || [])].join('\0')) }) + client.emit('neoforgeChannels', decoded) + return + } + if (setup?.get(client.state)?.has(packet.channel)) { + const entry = registrations[client.state][packet.channel] + if (entry.flow === 'serverbound') throw new Error('NeoForge sent a serverbound-only channel') + entry.handler(packet.data, { client, phase: client.state }) + } + } catch (error) { + failed = true + client.emit('error', error) + } + }) +} diff --git a/src/client/neoforgeRegistries.js b/src/client/neoforgeRegistries.js new file mode 100644 index 0000000..640162b --- /dev/null +++ b/src/client/neoforgeRegistries.js @@ -0,0 +1,63 @@ +const { ProtoDef } = require('protodef') +const { installCommandRegistryEntries } = require('./commandRegistry') +const proto = new ProtoDef(false) +proto.addTypes(require('./data/neoforgeRegistries.json').types) + +module.exports = function (client) { + let pending + let started = false + client.neoforgeRegistries = new Map() + client.neoforgeRegistrySyncComplete = false + client.on('state', state => { + if (state !== 'configuration') return + pending = undefined + started = false + client.neoforgeRegistrySyncComplete = false + client.neoforgeRegistries.clear() + }) + const decode = (type, bytes) => { + const parsed = proto.parsePacketBuffer(type, bytes) + if (parsed.metadata.size !== bytes.length) throw new Error('Trailing NeoForge registry data') + return parsed.data + } + return { + 'neoforge:frozen_registry_sync_start': { + version: '1', + flow: 'clientbound', + optional: true, + handler: bytes => { + if (started) throw new Error('Duplicate NeoForge registry sync start') + const names = decode('names', bytes) + pending = new Set(names) + if (pending.size !== names.length) throw new Error('Duplicate NeoForge registry name') + started = true + } + }, + 'neoforge:frozen_registry': { + version: '1', + flow: 'clientbound', + optional: true, + handler: bytes => { + const registry = decode('registry', bytes) + if (!pending?.has(registry.name)) throw new Error('Unexpected NeoForge registry') + const ids = new Set(registry.ids.map(entry => entry.value)) + const names = new Set(registry.ids.map(entry => entry.key)) + if (ids.size !== registry.ids.length || names.size !== registry.ids.length || registry.ids.some(entry => entry.value < 0)) throw new Error('Duplicate or invalid NeoForge registry entry') + if (registry.name === 'minecraft:command_argument_type') installCommandRegistryEntries(client, registry.ids) + client.neoforgeRegistries.set(registry.name, registry) + pending.delete(registry.name) + client.emit('neoforgeRegistry', registry) + } + }, + 'neoforge:frozen_registry_sync_completed': { + version: '1', + optional: true, + handler: bytes => { + if (bytes.length || !pending || pending.size) throw new Error('Incomplete NeoForge registry sync') + pending = undefined + client.write('custom_payload', { channel: 'neoforge:frozen_registry_sync_completed', data: Buffer.alloc(0) }) + client.neoforgeRegistrySyncComplete = true + } + } + } +} diff --git a/test/autoVersionForgeTest.js b/test/autoVersionForgeTest.js new file mode 100644 index 0000000..12adc6c --- /dev/null +++ b/test/autoVersionForgeTest.js @@ -0,0 +1,94 @@ +'use strict' +/* eslint-env mocha */ + +const assert = require('assert') +const { EventEmitter } = require('events') +const autoVersionForge = require('../src/client/autoVersionForge') + +function string (value) { + const bytes = Buffer.from(value) + assert.ok(bytes.length < 128) + return Buffer.concat([Buffer.from([bytes.length]), bytes]) +} + +function wrapper (payload) { + assert.ok(payload.length < 128) + return Buffer.concat([string('fml:handshake'), Buffer.from([payload.length]), payload]) +} + +function clientFor (response, options) { + const client = new EventEmitter() + client.writes = [] + client.write = (name, data) => client.writes.push({ name, data }) + client.registerChannel = () => {} + client.on('login_plugin_request', function onLoginPluginRequest () {}) + autoVersionForge(client, options) + for (const hook of client.autoVersionHooks) hook(response, client) + return client +} + +describe('automatic Forge protocol selection', () => { + it('selects modern Forge from network version 0 without installing FML3', () => { + const client = clientFor({ forgeData: { fmlNetworkVersion: 0, d: 'compressed-ping', mods: [] } }, { channels: { 'forge:handshake': 99 } }) + assert.equal(client.tagHost, '\0FORGE\0') + assert.equal(client.listenerCount('login_plugin_request'), 1) + client.state = 'configuration' + client.emit('custom_payload', { channel: 'forge:handshake', data: Buffer.from([2, 0]) }) + assert.deepStrictEqual(client.writes[0].data.data, Buffer.concat([Buffer.from([2, 1]), string('forge:handshake'), Buffer.from([99])])) + }) + + it('does not guess a handshake for an unknown network version', () => { + const client = clientFor({ forgeData: { fmlNetworkVersion: 99, d: 'compressed-ping' } }) + assert.equal(client.tagHost, undefined) + assert.equal(client.listenerCount('login_plugin_request'), 1) + }) + + it('accepts decoded FML3 ping mods without requiring the compressed d field', () => { + const client = clientFor({ forgeData: { fmlNetworkVersion: 3, mods: [{ modId: 'servermod', modVersion: '1' }] } }) + assert.equal(client.tagHost, '\0FML3\0') + const payload = Buffer.concat([Buffer.from([1, 1]), string('servermod'), Buffer.from([0, 0, 0])]) + client.emit('login_plugin_request', { channel: 'fml:loginwrapper', messageId: 4, data: wrapper(payload) }) + assert.deepStrictEqual(client.writes[0].data.data, wrapper(Buffer.concat([Buffer.from([2, 1]), string('servermod'), Buffer.from([0, 0])]))) + }) + + it('leaves vanilla clients alone', () => { + const client = clientFor({ version: { protocol: 763 } }) + assert.equal(client.tagHost, undefined) + assert.equal(client.listenerCount('login_plugin_request'), 1) + }) + + it('preserves FML1 mod list objects during a reset handshake', () => { + const client = clientFor({ modinfo: { type: 'FML', modList: [{ modid: 'example', version: '1' }] } }) + assert.equal(client.tagHost, '\0FML\0') + client.emit('custom_payload', { channel: 'FML|HS', data: Buffer.from([254]) }) + client.emit('custom_payload', { channel: 'FML|HS', data: Buffer.from([0, 2, 0, 0, 0, 0]) }) + const modList = client.writes.find(packet => packet.data.channel === 'FML|HS' && packet.data.data[0] === 2) + assert.ok(modList) + assert.deepStrictEqual(modList.data.data, Buffer.concat([Buffer.from([2, 1]), string('example'), string('1')])) + }) + + it('accepts the opening FML1 ServerHello without a preceding reset', () => { + const client = clientFor({ modinfo: { type: 'FML', modList: [] } }) + client.emit('custom_payload', { channel: 'FML|HS', data: Buffer.from([0, 2, 0, 0, 0, 0]) }) + assert.equal(client.fmlHandshakeState, 2) + assert.equal(client.fmlHandshakeReset, undefined) + assert.ok(client.writes.some(packet => packet.data.channel === 'FML|HS' && packet.data.data.equals(Buffer.from([1, 2])))) + }) + + for (const family of [2, 3]) { + for (const override of [undefined, [], ['explicitmod']]) { + it(`preserves FML${family} mod advertisement with override ${JSON.stringify(override)}`, () => { + const response = family === 2 + ? { forgeData: { fmlNetworkVersion: 2, mods: [{ modId: 'servermod', modmarker: 'ANY' }] } } + : { forgeData: { fmlNetworkVersion: 3, d: 'compressed-ping', mods: [] } } + const client = clientFor(response, override === undefined ? {} : { forgeMods: override }) + assert.equal(client.tagHost, `\0FML${family}\0`) + const payload = Buffer.concat([Buffer.from([1, 1]), string('servermod'), Buffer.from(family === 2 ? [0, 0] : [0, 0, 0])]) + client.emit('login_plugin_request', { channel: 'fml:loginwrapper', messageId: 4, data: wrapper(payload) }) + const names = override === undefined ? ['servermod'] : override + const expected = Buffer.concat([Buffer.from([2, names.length]), ...names.map(string), Buffer.from([0, 0])]) + assert.deepStrictEqual(client.writes[0], { name: 'login_plugin_response', data: { messageId: 4, data: wrapper(expected) } }) + }) + } + } +}) diff --git a/test/commandRegistryTest.js b/test/commandRegistryTest.js new file mode 100644 index 0000000..d2cc1e5 --- /dev/null +++ b/test/commandRegistryTest.js @@ -0,0 +1,118 @@ +/* eslint-env mocha */ +const assert = require('assert') +const { ProtoDef } = require('protodef') +const minecraftData = require('minecraft-data') +const { createDeserializer } = require('minecraft-protocol') +const { readRegistry, installCommandRegistry } = require('../src/client/commandRegistry') +const fixture = require('./fixtures/forge-1.20.1-commands.json') + +const snapshot = Buffer.from(fixture.snapshot, 'hex') +const packet = Buffer.from(fixture.commands, 'hex') +const options = { version: fixture.version, state: 'play', isServer: false } +function protocol (client) { + return createDeserializer({ ...options, customPackets: client.customPackets }).proto +} + +function encodeRegistry (ids) { + const encoder = new ProtoDef(false) + encoder.addType('string', ['pstring', { countType: 'varint' }]) + encoder.addType('snapshot', ['container', require('../src/client/data/fml3.json').types.forge_snapshot[1].slice(0, 4)]) + return encoder.createPacketBuffer('snapshot', { ids, aliases: [], overrides: [], blocked: [] }) +} + +describe('Forge command registries', () => { + it('reads old and new snapshot tails without discarding unknown trailing bytes', () => { + assert.deepStrictEqual(readRegistry(Buffer.concat([snapshot, Buffer.from([0])])), readRegistry(snapshot)) + assert.throws(() => readRegistry(Buffer.concat([snapshot, Buffer.from([0, 1])])), /trailing/) + assert.throws(() => readRegistry(snapshot.subarray(0, snapshot.length - 1))) + }) + + it('parses the complete captured packet without changing shared data', () => { + const shared = JSON.stringify(minecraftData(fixture.version).protocol) + createDeserializer(options) // warm the vanilla cache first + const client = { version: fixture.version } + installCommandRegistry(client, snapshot) + const parsed = protocol(client).parsePacketBuffer('packet', packet) + assert.equal(parsed.metadata.size, packet.length) + assert.equal(parsed.data.params.rootIndex, 0) + assert.equal(parsed.data.params.nodes.length, 33) + assert.equal(JSON.stringify(minecraftData(fixture.version).protocol), shared) + }) + + it('isolates two clients whose server registries assign different numeric IDs', () => { + const encoder = new ProtoDef(false) + encoder.addType('string', ['pstring', { countType: 'varint' }]) + encoder.addType('snapshot', ['container', require('../src/client/data/fml3.json').types.forge_snapshot[1].slice(0, 4)]) + const shifted = encoder.createPacketBuffer('snapshot', { + ids: readRegistry(snapshot).map(entry => ({ key: entry.key, value: entry.value + 100 })), + aliases: [], + overrides: [], + blocked: [] + }) + const first = { version: fixture.version } + const second = { version: fixture.version } + installCommandRegistry(first, snapshot) + installCommandRegistry(second, shifted) + const a = protocol(first) + const b = protocol(second) + const original = a.parsePacketBuffer('packet', packet).data + const encoded = b.createPacketBuffer('packet', original) + assert.notDeepStrictEqual(encoded, packet) + assert.deepStrictEqual(b.parsePacketBuffer('packet', encoded).data, original) + assert.deepStrictEqual(a.parsePacketBuffer('packet', packet).data, original) + installCommandRegistry(first, shifted) + const replacement = protocol(first) + assert.deepStrictEqual(replacement.parsePacketBuffer('packet', encoded).data, original) + assert.deepStrictEqual(a.parsePacketBuffer('packet', packet).data, original) + }) + + it('does not replace an existing caller-supplied command schema', () => { + const client = { version: fixture.version, customPackets: { '1.20': { types: { command_node: 'void' } } } } + const original = client.customPackets + assert.throws(() => installCommandRegistry(client, snapshot), /already installed/) + assert.strictEqual(client.customPackets, original) + }) + + it('leaves pre-numeric argument protocols unchanged', () => { + const client = { version: '1.18.2' } + installCommandRegistry(client, snapshot) + assert.equal(client.customPackets, undefined) + }) + + for (const version of ['1.13.2', '1.16.5', '1.17.1', '1.18.2']) { + it(`installs Forge properties without changing the string parser on ${version}`, () => { + const client = { version } + installCommandRegistry(client) + const node = client.customPackets[minecraftData(version).version.majorVersion].types.forge_command_node + const fields = node[1].find(field => field.name === 'extraNodeData').type[1].fields[2][1] + assert.equal(fields.find(field => field.name === 'parser').type, 'string') + assert.equal(fields.find(field => field.name === 'properties').type[1].fields['forge:enum'], 'string') + const proto = createDeserializer({ version, state: 'play', customPackets: client.customPackets }).proto + const flags = { unused: 0, has_custom_suggestions: 0, has_redirect_node: 0, has_command: 0, command_node_type: 0 } + const encoded = proto.createPacketBuffer('packet', { + name: 'declare_commands', + params: { + rootIndex: 0, + nodes: [ + { flags, children: [1] }, + { flags: { ...flags, has_command: 1, command_node_type: 2 }, children: [], extraNodeData: { name: 'test', parser: 'forge:enum', properties: 'example.Enum' } } + ] + } + }) + const parsed = proto.parsePacketBuffer('packet', encoded) + assert.equal(parsed.metadata.size, encoded.length) + assert.equal(parsed.data.params.nodes[1].extraNodeData.properties, 'example.Enum') + }) + } + + it('rejects duplicate registry IDs and unsupported argument types without installing a partial schema', () => { + const client = { version: fixture.version } + assert.throws(() => installCommandRegistry(client, encodeRegistry([ + { key: 'forge:enum', value: 0 }, { key: 'forge:modid', value: 0 } + ])), /Duplicate or invalid/) + assert.throws(() => installCommandRegistry(client, encodeRegistry([ + { key: 'example:unknown', value: 0 } + ])), /Unsupported Forge command argument type/) + assert.equal(client.customPackets, undefined) + }) +}) diff --git a/test/fabricNetworkingTest.js b/test/fabricNetworkingTest.js new file mode 100644 index 0000000..c1ab9b4 --- /dev/null +++ b/test/fabricNetworkingTest.js @@ -0,0 +1,103 @@ +/* eslint-env mocha */ +const assert = require('assert') +const { EventEmitter } = require('events') +const { fabricNetworking } = require('..') + +const string = value => Buffer.concat([Buffer.from([Buffer.byteLength(value)]), Buffer.from(value)]) +const registration = (phase, channels = []) => Buffer.concat([Buffer.from([1]), string(phase), Buffer.from([channels.length]), ...channels.map(string)]) +function setup (options) { + const client = new EventEmitter() + client.state = 'configuration' + client.writes = [] + client.errors = [] + client.write = (name, data) => client.writes.push({ name, ...data }) + client.on('error', error => client.errors.push(error)) + const networking = fabricNetworking(client, options) + client.receive = (channel, data) => client.emit('custom_payload', { channel, data }) + return { client, networking } +} + +describe('Fabric common networking', () => { + it('answers legacy early registration with only supplied play handlers', () => { + const { client, networking } = setup({ playHandlers: { 'example:client': () => {} } }) + client.emit('login_plugin_request', { messageId: 7, channel: 'fabric-networking-api-v1:early_registration', data: Buffer.concat([Buffer.from([1]), string('example:server')]) }) + assert.deepStrictEqual(client.writes, [{ name: 'login_plugin_response', messageId: 7, data: Buffer.concat([Buffer.from([1]), string('example:client')]) }]) + assert.equal(networking.remoteChannels.play.has('example:server'), true) + }) + + it('awaits explicit login handlers and declines unknown queries', async () => { + const { client } = setup({ loginHandlers: { 'example:login': async () => Buffer.from([42]) } }) + client.emit('login_plugin_request', { messageId: 8, channel: 'example:login', data: Buffer.alloc(0) }) + await new Promise(resolve => setImmediate(resolve)) + assert.deepStrictEqual(client.writes[0], { name: 'login_plugin_response', messageId: 8, data: Buffer.from([42]) }) + client.emit('login_plugin_request', { messageId: 9, channel: 'unknown:login', data: Buffer.alloc(0) }) + assert.deepStrictEqual(client.writes[1], { name: 'login_plugin_response', messageId: 9, data: undefined }) + }) + + it('reports rejected asynchronous login handlers without a success response', async () => { + const { client } = setup({ loginHandlers: { 'example:login': async () => { throw new Error('unsupported mod') } } }) + client.emit('login_plugin_request', { messageId: 8, channel: 'example:login', data: Buffer.alloc(0) }) + await new Promise(resolve => setImmediate(resolve)) + assert.match(client.errors[0].message, /unsupported mod/) + assert.equal(client.writes.length, 0) + }) + + it('advertises only implemented channels and negotiates the captured version bytes', () => { + const { client } = setup({ configurationHandlers: { 'example:config': () => {} } }) + client.receive('minecraft:register', ['c:version', 'c:register', 'server:only']) + assert.equal(client.writes[0].data.toString(), 'c:version\0c:register\0example:config') + client.receive('c:version', Buffer.from([1, 1])) + assert.deepStrictEqual(client.writes[1].data, Buffer.from([1, 1])) + client.receive('c:register', registration('play', ['example:remote'])) + assert.deepStrictEqual(client.writes[2].data, registration('play', ['c:version', 'c:register'])) + assert.equal(client.errors.length, 0) + }) + + it('routes handlers by phase without automatically acknowledging mod data', () => { + const calls = [] + const { client } = setup({ configurationHandlers: { 'example:data': (data, context) => calls.push(context.phase) }, playHandlers: { 'example:data': (data, context) => calls.push(context.phase) } }) + client.receive('example:data', Buffer.from([1])) + client.state = 'play' + client.receive('example:data', Buffer.from([2])) + assert.deepStrictEqual(calls, ['configuration', 'play']) + assert.equal(client.writes.length, 0) + }) + + it('rejects registration before negotiation and stops after failure', () => { + const { client } = setup() + client.receive('c:register', registration('play')) + client.receive('c:version', Buffer.from([1, 1])) + assert.equal(client.errors.length, 1) + assert.equal(client.writes.length, 0) + }) + + for (const bytes of [[1, 2], [1], [1, 1, 0]]) { + it(`rejects unsupported or malformed version bytes ${bytes}`, () => { + const { client } = setup() + client.receive('c:version', Buffer.from(bytes)) + assert.equal(client.errors.length, 1) + assert.equal(client.writes.length, 0) + }) + } + + it('rejects unknown registration phases', () => { + const { client } = setup() + client.receive('c:version', Buffer.from([1, 1])) + client.receive('c:register', registration('invalid')) + assert.equal(client.errors.length, 1) + assert.equal(client.writes.length, 1) + }) + + it('clears per-connection state on reconfiguration and handles unregister', () => { + const { client, networking } = setup() + client.receive('minecraft:register', Buffer.from('example:remote')) + assert.equal(networking.remoteChannels.configuration.has('example:remote'), true) + client.receive('minecraft:unregister', Buffer.from('example:remote')) + assert.equal(networking.remoteChannels.configuration.size, 0) + client.emit('state', 'configuration') + client.receive('minecraft:register', Buffer.from('c:version')) + assert.equal(client.writes.length, 2) + client.receive('c:register', registration('play')) + assert.equal(client.errors.length, 1) + }) +}) diff --git a/test/fabricRegistriesTest.js b/test/fabricRegistriesTest.js new file mode 100644 index 0000000..c0e8af6 --- /dev/null +++ b/test/fabricRegistriesTest.js @@ -0,0 +1,109 @@ +/* eslint-env mocha */ +const assert = require('assert') +const { EventEmitter } = require('events') +const decode = require('../src/client/fabricRegistries') +const { fabricNetworking } = require('..') +const string = value => Buffer.concat([Buffer.from([Buffer.byteLength(value)]), Buffer.from(value)]) +const payload = (attributes = 0) => Buffer.concat([ + Buffer.from([1]), string(''), Buffer.from([1]), string('sound_event'), Buffer.from([attributes, 2]), + string(''), Buffer.from([1, 3, 2]), string('a'), string('b'), + string('example'), Buffer.from([1, 0xfd, 0xff, 0xff, 0xff, 0x0f, 1]), string('c') +]) + +describe('Fabric registry synchronization', () => { + it('decodes the complete live Better Combat 1.21.11 registry payload', () => { + const fixture = require('./fixtures/fabric-1.21.11-registries.json') + const bytes = require('zlib').gunzipSync(Buffer.from(fixture.gzipBase64, 'base64')) + assert.equal(bytes.length, fixture.byteLength) + assert.equal(require('crypto').createHash('sha256').update(bytes).digest('hex'), fixture.sha256) + const registries = decode(bytes) + assert.equal(registries.size, 3) + assert.equal(registries.get('minecraft:data_component_type').entries.size, 105) + assert.equal(registries.get('minecraft:data_component_type').entries.get('bettercombat:preset_id'), 104) + assert.equal(registries.get('minecraft:sound_event').entries.size, 1864) + assert.equal(registries.get('minecraft:particle_type').entries.size, 127) + }) + it('decodes default namespaces, consecutive IDs and negative deltas between namespaces', () => { + const registry = decode(payload(1)).get('minecraft:sound_event') + assert.equal(registry.optional, true) + assert.deepStrictEqual([...registry.entries], [['minecraft:a', 3], ['minecraft:b', 4], ['example:c', 1]]) + }) + + it('rejects truncated, trailing and unknown-attribute data', () => { + const bytes = payload() + for (let end = 0; end < bytes.length; end++) assert.throws(() => decode(bytes.subarray(0, end))) + assert.throws(() => decode(Buffer.concat([bytes, Buffer.from([0])])), /Trailing/) + assert.throws(() => decode(payload(2)), /attributes/) + }) + + it('rejects duplicate registry names and invalid entry IDs', () => { + const bytes = payload() + const registryBody = bytes.subarray(3) + assert.throws(() => decode(Buffer.concat([Buffer.from([1, 0, 2]), registryBody, registryBody])), /Duplicate Fabric registry/) + const negative = Buffer.concat([Buffer.from([1, 0, 1]), string('sound_event'), Buffer.from([0, 1, 0, 1, 0xff, 0xff, 0xff, 0xff, 0x0f, 1]), string('a')]) + assert.throws(() => decode(negative), /invalid Fabric registry entry/) + }) + + it('acknowledges only after the application explicitly confirms applying the mappings', () => { + for (const accepted of [true, false, undefined]) { + const client = new EventEmitter() + client.version = '1.21.11' + client.state = 'configuration' + const writes = [] + const errors = [] + client.write = (name, packet) => writes.push(packet) + client.on('error', error => errors.push(error)) + let calls = 0 + fabricNetworking(client, { + registryHandler: registries => { + calls++ + assert.equal(registries.get('minecraft:sound_event').entries.get('example:c'), 1) + assert.equal(writes.length, 0) + return accepted + } + }) + client.emit('custom_payload', { channel: 'fabric:registry/sync', data: payload() }) + assert.equal(calls, 1) + assert.equal(errors.length, accepted === true ? 0 : 1) + assert.deepStrictEqual(writes, accepted === true ? [{ channel: 'fabric:registry/sync/complete', data: Buffer.alloc(0) }] : []) + } + }) + + it('does not enable registry sync for an unverified wire version', () => { + const client = new EventEmitter() + client.version = '1.21.1' + assert.throws(() => fabricNetworking(client, { registryHandler: () => true }), /only verified/) + }) + + it('does not call the application for malformed payloads', () => { + const client = new EventEmitter() + client.version = '1.21.11' + client.state = 'configuration' + const errors = [] + client.on('error', error => errors.push(error)) + client.write = () => assert.fail('must not acknowledge malformed data') + fabricNetworking(client, { registryHandler: () => assert.fail('must not apply malformed data') }) + client.emit('custom_payload', { channel: 'fabric:registry/sync', data: Buffer.from([1]) }) + assert.equal(errors.length, 1) + }) + + it('permits registry synchronization once per configuration cycle', () => { + const client = new EventEmitter() + client.version = '1.21.11' + client.state = 'configuration' + const errors = [] + let writes = 0 + client.on('error', error => errors.push(error)) + client.write = () => writes++ + fabricNetworking(client, { registryHandler: () => true }) + const receive = () => client.emit('custom_payload', { channel: 'fabric:registry/sync', data: payload() }) + receive() + client.emit('state', 'configuration') + receive() + assert.equal(writes, 2) + assert.equal(errors.length, 0) + receive() + assert.equal(writes, 2) + assert.match(errors[0].message, /Duplicate Fabric registry/) + }) +}) diff --git a/test/fabricRegistryMappingsTest.js b/test/fabricRegistryMappingsTest.js new file mode 100644 index 0000000..ac4ed9b --- /dev/null +++ b/test/fabricRegistryMappingsTest.js @@ -0,0 +1,173 @@ +/* eslint-env mocha */ +const assert = require('assert') +const { gunzipSync } = require('zlib') +const minecraftData = require('minecraft-data') +const { createDeserializer } = require('minecraft-protocol') +const decodeRegistries = require('../src/client/fabricRegistries') +const { installFabricRegistryMappings } = require('..') + +const fixture = require('./fixtures/fabric-1.21.11-registries.json') +const registries = () => decodeRegistries(gunzipSync(Buffer.from(fixture.gzipBase64, 'base64'))) +const slash = ['container', [ + { name: 'scale', type: 'f32' }, + { name: 'pitch', type: 'f32' }, + { name: 'yaw', type: 'f32' }, + { name: 'localYaw', type: 'f32' }, + { name: 'roll', type: 'f32' }, + { name: 'light', type: 'bool' }, + { name: 'color', type: 'i64' } +]] +const options = { + particleTypes: Object.fromEntries([ + 'botslash45', 'botslash90', 'botslash180', 'botslash270', 'botslash360', 'botstab', + 'topslash45', 'topslash90', 'topslash180', 'topslash270', 'topslash360', 'topstab' + ].map(name => [`bettercombat:${name}`, slash])), + dataComponentTypes: { 'bettercombat:preset_id': 'anonymousNbt' } +} + +function protocol (client) { + return createDeserializer({ version: client.version, state: 'play', isServer: false, customPackets: client.customPackets }).proto +} + +describe('Fabric registry mappings', () => { + it('installs the complete captured mappings without mutating minecraft-data', () => { + const shared = JSON.stringify(minecraftData('1.21.11').protocol) + const client = { version: '1.21.11' } + assert.equal(installFabricRegistryMappings(client, registries(), options), true) + assert.equal(client.fabricRegistries.get('minecraft:sound_event').entries.get('bettercombat:sword_slash'), 1862) + assert.equal(JSON.stringify(minecraftData('1.21.11').protocol), shared) + }) + + it('round-trips a custom particle using its synchronized ID and codec', () => { + const client = { version: '1.21.11' } + installFabricRegistryMappings(client, registries(), options) + const proto = protocol(client) + const params = { + longDistance: false, + alwaysShow: true, + x: 1, + y: 2, + z: 3, + offsetX: 0, + offsetY: 0, + offsetZ: 0, + velocityOffset: 0, + amount: 1, + particle: { + type: 'bettercombat:botslash45', + data: { scale: 1, pitch: 2, yaw: 3, localYaw: 4, roll: 5, light: true, color: 6n } + } + } + const packet = proto.createPacketBuffer('packet', { name: 'world_particles', params }) + const parsed = proto.parsePacketBuffer('packet', packet) + assert.equal(parsed.metadata.size, packet.length) + assert.deepStrictEqual({ ...parsed.data.params, particle: { ...parsed.data.params.particle, data: { ...parsed.data.params.particle.data, color: undefined } } }, + { ...params, particle: { ...params.particle, data: { ...params.particle.data, color: undefined } } }) + assert.deepStrictEqual([...parsed.data.params.particle.data.color], [0, 6]) + assert.equal(packet.includes(Buffer.from([115])), true) + }) + + it('round-trips a custom item component using its synchronized ID and codec', () => { + const client = { version: '1.21.11' } + installFabricRegistryMappings(client, registries(), options) + const proto = protocol(client) + const params = { + windowId: 0, + stateId: 0, + slot: 0, + item: { + itemCount: 1, + itemId: 1, + addedComponentCount: 1, + removedComponentCount: 0, + components: [{ type: 'bettercombat:preset_id', data: { type: 'string', value: 'bettercombat:sword' } }], + removeComponents: [] + } + } + const packet = proto.createPacketBuffer('packet', { name: 'set_slot', params }) + const parsed = proto.parsePacketBuffer('packet', packet) + assert.equal(parsed.metadata.size, packet.length) + assert.deepStrictEqual(parsed.data.params, params) + }) + + it('parses the complete live custom particle and item packets', () => { + const play = require('./fixtures/fabric-1.21.11-better-combat-play.json') + const client = { version: play.version } + installFabricRegistryMappings(client, registries(), options) + const proto = protocol(client) + const particleBytes = Buffer.from(play.packets.particle, 'hex') + const particle = proto.parsePacketBuffer('packet', particleBytes) + assert.equal(particle.metadata.size, particleBytes.length) + assert.equal(particle.data.name, 'world_particles') + assert.equal(particle.data.params.particle.type, 'bettercombat:botslash45') + assert.deepStrictEqual([...particle.data.params.particle.data.color], [0, -1]) + const itemBytes = Buffer.from(play.packets.item, 'hex') + const item = proto.parsePacketBuffer('packet', itemBytes) + assert.equal(item.metadata.size, itemBytes.length) + assert.equal(item.data.name, 'set_slot') + assert.deepStrictEqual(item.data.params.item.components, [{ type: 'bettercombat:preset_id', data: { type: 'string', value: 'bettercombat:sword' } }]) + }) + + it('isolates clients with different synchronized particle IDs', () => { + const firstRegistries = registries() + const secondRegistries = registries() + const entries = secondRegistries.get('minecraft:particle_type').entries + entries.set('bettercombat:botslash45', 116) + entries.set('bettercombat:botslash90', 115) + const first = { version: '1.21.11' } + const second = { version: '1.21.11' } + installFabricRegistryMappings(first, firstRegistries, options) + installFabricRegistryMappings(second, secondRegistries, options) + const particle = { + type: 'bettercombat:botslash45', + data: { scale: 1, pitch: 0, yaw: 0, localYaw: 0, roll: 0, light: false, color: 0n } + } + const firstBytes = protocol(first).createPacketBuffer('Particle', particle) + const secondBytes = protocol(second).createPacketBuffer('Particle', particle) + assert.equal(firstBytes[0], 115) + assert.equal(secondBytes[0], 116) + assert.notDeepStrictEqual(firstBytes, secondBytes) + assert.equal(protocol(first).parsePacketBuffer('Particle', firstBytes).data.type, particle.type) + assert.equal(protocol(second).parsePacketBuffer('Particle', secondBytes).data.type, particle.type) + }) + + it('replaces only schemas previously installed for the same client', () => { + const client = { version: '1.21.11' } + installFabricRegistryMappings(client, registries(), options) + const before = protocol(client) + const replacement = registries() + const entries = replacement.get('minecraft:particle_type').entries + entries.set('bettercombat:botslash45', 116) + entries.set('bettercombat:botslash90', 115) + installFabricRegistryMappings(client, replacement, options) + const after = protocol(client) + const particle = { + type: 'bettercombat:botslash45', + data: { scale: 1, pitch: 0, yaw: 0, localYaw: 0, roll: 0, light: false, color: 0n } + } + assert.equal(before.createPacketBuffer('Particle', particle)[0], 115) + assert.equal(after.createPacketBuffer('Particle', particle)[0], 116) + }) + + it('accepts a supported partial registry set and removes stale owned schemas', () => { + const client = { version: '1.21.11' } + installFabricRegistryMappings(client, registries(), options) + const soundOnly = new Map([['minecraft:sound_event', registries().get('minecraft:sound_event')]]) + installFabricRegistryMappings(client, soundOnly) + const types = client.customPackets[minecraftData(client.version).version.majorVersion].types + assert.equal(types.Particle, undefined) + assert.equal(types.SlotComponent, undefined) + assert.equal(client.fabricRegistries.size, 1) + assert.equal(client.fabricRegistries.get('minecraft:sound_event').entries.get('bettercombat:sword_slash'), 1862) + }) + + it('rejects incomplete codecs, unknown registries, and caller schema conflicts', () => { + assert.throws(() => installFabricRegistryMappings({ version: '1.21.11' }, registries()), /Missing .* codec/) + const unknown = registries() + unknown.set('example:unknown', { optional: false, entries: new Map() }) + assert.throws(() => installFabricRegistryMappings({ version: '1.21.11' }, unknown, options), /Unsupported Fabric registry/) + const key = minecraftData('1.21.11').version.majorVersion + const client = { version: '1.21.11', customPackets: { [key]: { types: { Particle: 'void' } } } } + assert.throws(() => installFabricRegistryMappings(client, registries(), options), /already installed/) + }) +}) diff --git a/test/fixtures/fabric-1.21.11-better-combat-play.json b/test/fixtures/fabric-1.21.11-better-combat-play.json new file mode 100644 index 0000000..6c51f69 --- /dev/null +++ b/test/fixtures/fabric-1.21.11-better-combat-play.json @@ -0,0 +1,10 @@ +{ + "version": "1.21.11", + "mod": "bettercombat-fabric-3.1.0+1.21.11.jar", + "sourceRevision": "47ea3b6ae95d9d4a9262b76b85318fb453eb2cfe", + "evidenceRun": "runs/modded-matrix/2026-09-11T03-02-45-822Z-fabric-config-1.21.11", + "packets": { + "particle": "2e0100c01e000000000000c04d80000000000040120000000000000000000000000000000000000000000000000001733f800000000000000000000000000000000000000100000000ffffffff", + "item": "140002002401a807010068080012626574746572636f6d6261743a73776f7264" + } +} diff --git a/test/fixtures/fabric-1.21.11-registries.json b/test/fixtures/fabric-1.21.11-registries.json new file mode 100644 index 0000000..533d8f9 --- /dev/null +++ b/test/fixtures/fabric-1.21.11-registries.json @@ -0,0 +1,8 @@ +{ + "version": "1.21.11", + "mod": "Better Combat 3.1.0", + "source": "runs/modded-matrix/2026-09-11T02-17-34-768Z-fabric-config-1.21.11/evidence/probe-fabric-registry-observe.json", + "sha256": "05ef8ccff1acd6b71cb84b500ddae3ead0635feb6d05900c0cc63b57dafe31ef", + "byteLength": 47540, + "gzipBase64": "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" +} diff --git a/test/fixtures/forge-1.20.1-commands.json b/test/fixtures/forge-1.20.1-commands.json new file mode 100644 index 0000000..87e2180 --- /dev/null +++ b/test/fixtures/forge-1.20.1-commands.json @@ -0,0 +1,6 @@ +{ + "source": "runs/modded-matrix/2026-09-10T22-42-38-582Z-forge-fml3-1.20.1/evidence/probe-forge-auto.json", + "version": "1.20.1", + "snapshot": "350e6272696761646965723a626f6f6c00106272696761646965723a646f75626c65020f6272696761646965723a666c6f617401116272696761646965723a696e7465676572030e6272696761646965723a6c6f6e6704106272696761646965723a737472696e67050a666f7267653a656e756d330b666f7267653a6d6f646964340f6d696e6563726166743a616e676c651a136d696e6563726166743a626c6f636b5f706f7308196d696e6563726166743a626c6f636b5f7072656469636174650d156d696e6563726166743a626c6f636b5f73746174650c0f6d696e6563726166743a636f6c6f7210146d696e6563726166743a636f6c756d6e5f706f7309136d696e6563726166743a636f6d706f6e656e7411136d696e6563726166743a64696d656e73696f6e26106d696e6563726166743a656e7469747906176d696e6563726166743a656e746974795f616e63686f7223156d696e6563726166743a666c6f61745f72616e676525126d696e6563726166743a66756e6374696f6e22166d696e6563726166743a67616d655f70726f66696c6507126d696e6563726166743a67616d656d6f646527136d696e6563726166743a6865696768746d61702f136d696e6563726166743a696e745f72616e676524186d696e6563726166743a6974656d5f7072656469636174650f136d696e6563726166743a6974656d5f736c6f7420146d696e6563726166743a6974656d5f737461636b0e116d696e6563726166743a6d657373616765121a6d696e6563726166743a6e62745f636f6d706f756e645f74616713126d696e6563726166743a6e62745f7061746815116d696e6563726166743a6e62745f74616714136d696e6563726166743a6f626a656374697665161c6d696e6563726166743a6f626a6563746976655f637269746572696117136d696e6563726166743a6f7065726174696f6e18126d696e6563726166743a7061727469636c6519126d696e6563726166743a7265736f757263652b166d696e6563726166743a7265736f757263655f6b65792c1b6d696e6563726166743a7265736f757263655f6c6f636174696f6e21196d696e6563726166743a7265736f757263655f6f725f746167291d6d696e6563726166743a7265736f757263655f6f725f7461675f6b65792a126d696e6563726166743a726f746174696f6e1b166d696e6563726166743a73636f72655f686f6c6465721d196d696e6563726166743a73636f7265626f6172645f736c6f741c116d696e6563726166743a7377697a7a6c651e0e6d696e6563726166743a7465616d1f196d696e6563726166743a74656d706c6174655f6d6972726f722d1b6d696e6563726166743a74656d706c6174655f726f746174696f6e2e176d696e6563726166743a746573745f617267756d656e7431146d696e6563726166743a746573745f636c617373320e6d696e6563726166743a74696d65280e6d696e6563726166743a75756964300e6d696e6563726166743a766563320b0e6d696e6563726166743a766563330a000000", + "commands": "1021000b0102030405060708090a0b01010c026d6505010d0468656c7005010e046c69737401010f036d73670900040474656c6c0900040177010110077465616d6d736709000702746d01011107747269676765720105121314151605666f72676501011706636f6e666967060006616374696f6e12060007636f6d6d616e6405020500057575696473020118077461726765747306020600076d657373616765121602191a096f626a65637469766516146d696e6563726166743a61736b5f73657276657205011b0374707301021c1d05747261636b010006656e7469747905000a64696d656e73696f6e730500046d6f647301011e0873686f7766696c650600076d6573736167651201011f0361646401011f0373657406000364696d26050006656e746974790500027465020120036d6f643406000576616c7565030006000474797065332c6e65742e6d696e656372616674666f7267652e666d6c2e636f6e6669672e4d6f64436f6e666967245479706500" +} diff --git a/test/fixtures/neoforge-1.21.1-commands.json b/test/fixtures/neoforge-1.21.1-commands.json new file mode 100644 index 0000000..6776071 --- /dev/null +++ b/test/fixtures/neoforge-1.21.1-commands.json @@ -0,0 +1,7 @@ +{ + "source": "runs/modded-matrix/2026-09-11T01-38-07-035Z-neoforge-config-1.21.1/evidence/probe-neoforge.json", + "version": "1.21.1", + "server": "NeoForge 21.1.250", + "registry": "1f6d696e6563726166743a636f6d6d616e645f617267756d656e745f747970653a000e6272696761646965723a626f6f6c010f6272696761646965723a666c6f617402106272696761646965723a646f75626c6503116272696761646965723a696e7465676572040e6272696761646965723a6c6f6e6705106272696761646965723a737472696e6706106d696e6563726166743a656e7469747907166d696e6563726166743a67616d655f70726f66696c6508136d696e6563726166743a626c6f636b5f706f7309146d696e6563726166743a636f6c756d6e5f706f730a0e6d696e6563726166743a766563330b0e6d696e6563726166743a766563320c156d696e6563726166743a626c6f636b5f73746174650d196d696e6563726166743a626c6f636b5f7072656469636174650e146d696e6563726166743a6974656d5f737461636b0f186d696e6563726166743a6974656d5f707265646963617465100f6d696e6563726166743a636f6c6f7211136d696e6563726166743a636f6d706f6e656e74120f6d696e6563726166743a7374796c6513116d696e6563726166743a6d657373616765141a6d696e6563726166743a6e62745f636f6d706f756e645f74616715116d696e6563726166743a6e62745f74616716126d696e6563726166743a6e62745f7061746817136d696e6563726166743a6f626a656374697665181c6d696e6563726166743a6f626a6563746976655f637269746572696119136d696e6563726166743a6f7065726174696f6e1a126d696e6563726166743a7061727469636c651b0f6d696e6563726166743a616e676c651c126d696e6563726166743a726f746174696f6e1d196d696e6563726166743a73636f7265626f6172645f736c6f741e166d696e6563726166743a73636f72655f686f6c6465721f116d696e6563726166743a7377697a7a6c65200e6d696e6563726166743a7465616d21136d696e6563726166743a6974656d5f736c6f7422146d696e6563726166743a6974656d5f736c6f7473231b6d696e6563726166743a7265736f757263655f6c6f636174696f6e24126d696e6563726166743a66756e6374696f6e25176d696e6563726166743a656e746974795f616e63686f7226136d696e6563726166743a696e745f72616e676527156d696e6563726166743a666c6f61745f72616e676528136d696e6563726166743a64696d656e73696f6e29126d696e6563726166743a67616d656d6f64652a0e6d696e6563726166743a74696d652b196d696e6563726166743a7265736f757263655f6f725f7461672c1d6d696e6563726166743a7265736f757263655f6f725f7461675f6b65792d126d696e6563726166743a7265736f757263652e166d696e6563726166743a7265736f757263655f6b65792f196d696e6563726166743a74656d706c6174655f6d6972726f72301b6d696e6563726166743a74656d706c6174655f726f746174696f6e31136d696e6563726166743a6865696768746d617032146d696e6563726166743a6c6f6f745f7461626c6533186d696e6563726166743a6c6f6f745f70726564696361746534176d696e6563726166743a6c6f6f745f6d6f646966696572350e6d696e6563726166743a7575696436176d696e6563726166743a746573745f617267756d656e7437146d696e6563726166743a746573745f636c617373380d6e656f666f7267653a656e756d390e6e656f666f7267653a6d6f64696400", + "commands": "1129000c0102030405060708090a0b0c01010d026d6505010e0468656c7005010f046c697374010110036d73670900040474656c6c0900040177010211120672616e646f6d010113077465616d6d736709000802746d0101140774726967676572010615161718191a086e656f666f72676501011b06636f6e666967060006616374696f6e13060007636f6d6d616e640502050005757569647302011c0774617267657473060201011d0576616c756501011e04726f6c6c0600076d6573736167651316021f20096f626a65637469766517146d696e6563726166743a61736b5f736572766572050121037470730102222305747261636b010006656e7469747905000a64696d656e73696f6e730500046d6f647305022425036461790101260873686f7766696c650600076d6573736167651306000572616e67652606000572616e676526010127036164640101270373657406000964696d656e73696f6e28050006656e7469747905000b626c6f636b656e7469747905000573706565640500066c656e677468020128036d6f643906000576616c756503000600047479706538476e65742e6e656f666f726765642e6e656f666f7267652e7365727665722e636f6d6d616e642e436f6e666967436f6d6d616e64245365727665724d6f64436f6e6669675479706500" +} diff --git a/test/forgeHandshake3Test.js b/test/forgeHandshake3Test.js new file mode 100644 index 0000000..541d599 --- /dev/null +++ b/test/forgeHandshake3Test.js @@ -0,0 +1,179 @@ +'use strict' +/* eslint-env mocha */ + +const assert = require('assert') +const { EventEmitter } = require('events') + +const forgeHandshake3 = require('../src/client/forgeHandshake3') + +function makeClient () { + const client = new EventEmitter() + client.registerChannel = () => {} + client.writes = [] + client.write = (name, data) => client.writes.push({ name, data }) + client.on('login_plugin_request', function onLoginPluginRequest () {}) + return client +} + +function string (value) { + const bytes = Buffer.from(value) + assert.ok(bytes.length < 128, 'test helper supports one-byte VarInts') + return Buffer.concat([Buffer.from([bytes.length]), bytes]) +} + +function loginWrapper (channel, payload) { + assert.ok(payload.length < 128, 'test helper supports one-byte VarInts') + return Buffer.concat([string(channel), Buffer.from([payload.length]), payload]) +} + +describe('FML3 handshake', () => { + for (const trailingList of [Buffer.alloc(0), Buffer.from([0]), Buffer.concat([Buffer.from([1]), string('example:registry')])]) { + it(`accepts ModList with ${trailingList.length === 0 ? 'omitted' : 'present'} data-pack registry list (${trailingList.length} bytes)`, () => { + const client = makeClient() + forgeHandshake3(client) + client.emit('login_plugin_request', { + messageId: 1, + channel: 'fml:loginwrapper', + data: loginWrapper('fml:handshake', Buffer.concat([Buffer.from([1, 0, 0, 0]), trailingList])) + }) + assert.deepStrictEqual(client.writes, [{ + name: 'login_plugin_response', + data: { messageId: 1, data: loginWrapper('fml:handshake', Buffer.from([2, 0, 0, 0])) } + }]) + }) + } + + it('does not answer the no-response ModData query', () => { + const client = makeClient() + forgeHandshake3(client, { forgeMods: [] }) + + client.emit('login_plugin_request', { + messageId: 0, + channel: 'fml:loginwrapper', + data: Buffer.from('0d666d6c3a68616e647368616b65310502096d696e656372616674094d696e65637261667406312e32302e3105666f72676505466f7267650734372e342e3130', 'hex') + }) + + assert.deepStrictEqual(client.writes, []) + }) + + it('acknowledges a server registry without a snapshot', () => { + const client = makeClient() + forgeHandshake3(client, { forgeMods: [] }) + + client.emit('login_plugin_request', { + messageId: 2, + channel: 'fml:loginwrapper', + data: loginWrapper('fml:handshake', Buffer.concat([ + Buffer.from([3]), + string('minecraft:test'), + Buffer.from([0]) + ])) + }) + + assert.equal(client.writes.length, 1) + assert.equal(client.writes[0].name, 'login_plugin_response') + assert.equal(client.writes[0].data.messageId, 2) + assert.ok(Buffer.isBuffer(client.writes[0].data.data)) + assert.equal(client.writes[0].data.data.toString('hex'), '0d666d6c3a68616e647368616b650163') + }) + + for (const legacyDummies of [false, true]) { + it(`acknowledges a registry snapshot ${legacyDummies ? 'with' : 'without'} legacy dummied entries`, () => { + const client = makeClient() + forgeHandshake3(client, { forgeMods: [] }) + const snapshotWithoutLegacyDummies = Buffer.concat([ + Buffer.from([1, 1]), // present snapshot, one ID entry + string('minecraft:test'), Buffer.from([0]), + Buffer.from([0]), // aliases + Buffer.from([0]), // overrides + Buffer.from([0]), // blocked ids + legacyDummies ? Buffer.concat([Buffer.from([1]), string('minecraft:removed')]) : Buffer.alloc(0) + ]) + + client.emit('login_plugin_request', { + messageId: 2, + channel: 'fml:loginwrapper', + data: loginWrapper('fml:handshake', Buffer.concat([ + Buffer.from([3]), + string('minecraft:test_registry'), + snapshotWithoutLegacyDummies + ])) + }) + + assert.equal(client.writes.length, 1) + assert.equal(client.writes[0].data.messageId, 2) + assert.equal(client.writes[0].data.data.toString('hex'), '0d666d6c3a68616e647368616b650163') + }) + } + + it('does not claim support for an unhandled mod login channel', () => { + const client = makeClient() + forgeHandshake3(client, { forgeMods: [] }) + let observed + client.once('forgeLoginPluginRequest', request => { observed = request }) + + client.emit('login_plugin_request', { + messageId: 23, + channel: 'fml:loginwrapper', + data: loginWrapper('tacz:handshake', Buffer.from([2, 0])) + }) + + assert.equal(observed.channel, 'tacz:handshake') + assert.deepStrictEqual(client.writes, [{ name: 'login_plugin_response', data: { messageId: 23 } }]) + }) + + it('wraps an explicit mod login handler response on the originating channel', () => { + const client = makeClient() + forgeHandshake3(client, { + forgeMods: ['tacz'], + loginHandlers: { + 'tacz:handshake': data => { + assert.equal(data[0], 2) + return Buffer.from([1]) + } + } + }) + + client.emit('login_plugin_request', { + messageId: 23, + channel: 'fml:loginwrapper', + data: loginWrapper('tacz:handshake', Buffer.from([2, 0])) + }) + + assert.equal(client.writes.length, 1) + assert.deepStrictEqual(client.writes[0], { + name: 'login_plugin_response', + data: { + messageId: 23, + data: loginWrapper('tacz:handshake', Buffer.from([1])) + } + }) + }) + + it('advertises only explicitly supplied mods and channel versions', () => { + const client = makeClient() + forgeHandshake3(client, { + forgeMods: ['declaredmod'], + channels: { 'declaredmod:handshake': '7' } + }) + const modList = Buffer.concat([ + Buffer.from([1, 1]), string('servermod'), + Buffer.from([0]), // channels + Buffer.from([0]), // registries + Buffer.from([0]) // data-pack registries + ]) + + client.emit('login_plugin_request', { + messageId: 1, + channel: 'fml:loginwrapper', + data: loginWrapper('fml:handshake', modList) + }) + + const expected = Buffer.concat([ + Buffer.from([2, 1]), string('declaredmod'), + Buffer.from([1]), string('declaredmod:handshake'), string('7'), + Buffer.from([0]) + ]) + assert.deepStrictEqual(client.writes[0].data.data, loginWrapper('fml:handshake', expected)) + }) +}) diff --git a/test/forgeHandshakeModernTest.js b/test/forgeHandshakeModernTest.js new file mode 100644 index 0000000..abe2406 --- /dev/null +++ b/test/forgeHandshakeModernTest.js @@ -0,0 +1,141 @@ +/* eslint-env mocha */ +const assert = require('assert') +const { EventEmitter } = require('events') +const modern = require('../src/client/forgeHandshakeModern') + +function string (value) { + const data = Buffer.from(value) + assert.ok(data.length < 128) + return Buffer.concat([Buffer.from([data.length]), data]) +} +function client (options) { + const result = new EventEmitter() + result.version = '1.20.2' + result.state = 'configuration' + result.writes = [] + result.errors = [] + result.write = (name, packet) => result.writes.push({ name, ...packet }) + result.on('error', error => result.errors.push(error.message)) + modern(result, options) + result.receive = data => result.emit('custom_payload', { channel: 'forge:handshake', data }) + return result +} + +describe('modern Forge configuration', () => { + it('preserves explicit empty mod and channel lists', () => { + const bot = client({ modVersions: [], channels: {} }) + bot.receive(Buffer.concat([Buffer.from([1, 1]), string('forge'), string('Forge'), string('48.1.0')])) + bot.receive(Buffer.from([2, 0])) + assert.deepStrictEqual(bot.writes.map(packet => packet.data), [Buffer.from([1, 0]), Buffer.from([2, 0])]) + }) + + it('rejects truncated and trailing data without a success response', () => { + for (const bytes of [[1], [2, 0, 1]]) { + const bot = client() + bot.receive(Buffer.from(bytes)) + assert.equal(bot.errors.length, 1) + assert.equal(bot.writes.length, 0) + bot.receive(Buffer.from([1, 0])) + assert.equal(bot.writes.length, 0) + } + }) + + it('does not process handshake packets outside configuration', () => { + const bot = client() + bot.state = 'play' + bot.receive(Buffer.from([1, 0])) + assert.equal(bot.writes.length, 0) + assert.deepStrictEqual(bot.errors, []) + }) + + it('rejects duplicate registry lists and duplicate names', () => { + const bot = client() + bot.receive(Buffer.from([3, 7, 0, 0])) + bot.receive(Buffer.from([3, 8, 0, 0])) + assert.equal(bot.writes.length, 1) + assert.match(bot.errors[0], /Duplicate modern Forge registry list/) + const other = client() + other.receive(Buffer.concat([Buffer.from([3, 7, 2]), string('example:test'), string('example:test'), Buffer.from([0])])) + assert.equal(other.writes.length, 0) + assert.match(other.errors[0], /Duplicate modern Forge registry name/) + }) + + it('answers the captured mod-list request and advertises only configured channels', () => { + const bot = client() + const mods = Buffer.from('0102096d696e656372616674094d696e65637261667406312e32302e3205666f72676505466f7267650634382e312e30', 'hex') + bot.receive(mods) + assert.deepStrictEqual(bot.writes[0].data, mods) + bot.receive(Buffer.from([2, 0])) + assert.deepStrictEqual(bot.writes[1].data, Buffer.concat([ + Buffer.from([2, 2]), string('forge:handshake'), Buffer.from([0]), string('forge:login'), Buffer.from([0]) + ])) + assert.deepStrictEqual(bot.errors, []) + }) + + it('acknowledges the matching registry tokens and waits for all registries', () => { + const bot = client() + bot.receive(Buffer.from([1, 0])) + bot.receive(Buffer.from([2, 0])) + bot.receive(Buffer.concat([Buffer.from([3, 7, 1]), string('example:test'), Buffer.from([0])])) + assert.deepStrictEqual(bot.writes.at(-1).data, Buffer.from([0, 7])) + bot.receive(Buffer.concat([Buffer.from([4, 8]), string('example:test'), Buffer.from([0, 0, 0, 0])])) + assert.deepStrictEqual(bot.writes.at(-1).data, Buffer.from([0, 8])) + assert.deepStrictEqual(bot.forgeRegistries.get('example:test'), []) + bot.state = 'play' + bot.emit('state', 'play') + assert.equal(bot.forgeHandshakeComplete, true) + assert.deepStrictEqual(bot.errors, []) + bot.state = 'configuration' + bot.emit('state', 'configuration') + assert.equal(bot.forgeHandshakeComplete, false) + assert.equal(bot.forgeRegistries.size, 0) + bot.receive(Buffer.from([1, 0])) + bot.receive(Buffer.from([2, 0])) + bot.receive(Buffer.from([3, 9, 0, 0])) + bot.state = 'play' + bot.emit('state', 'play') + assert.equal(bot.forgeHandshakeComplete, true) + assert.deepStrictEqual(bot.errors, []) + }) + + it('emits config contents without an unsolicited acknowledgment', () => { + const bot = client() + let config + bot.once('forgeConfig', (name, data) => { config = { name, data } }) + bot.receive(Buffer.concat([Buffer.from([5]), string('example.toml'), Buffer.from([2, 1, 2])])) + assert.deepStrictEqual(config, { name: 'example.toml', data: Buffer.from([1, 2]) }) + assert.equal(bot.writes.length, 0) + }) + + it('does not acknowledge unannounced registries or unsupported datapacks', () => { + const bot = client() + bot.receive(Buffer.concat([Buffer.from([4, 8]), string('example:test'), Buffer.from([0, 0, 0, 0])])) + const other = client() + other.receive(Buffer.concat([Buffer.from([3, 7, 0, 1]), string('example:required')])) + assert.equal(bot.writes.length, 0) + assert.equal(other.writes.length, 0) + assert.match(bot.errors[0], /Unexpected modern Forge registry/) + assert.match(other.errors[0], /Unsupported modern Forge data-pack registry/) + }) + + it('does not report completion when entering play before configuration', () => { + const bot = client() + bot.state = 'play' + bot.emit('state', 'play') + assert.equal(bot.forgeHandshakeComplete, false) + assert.match(bot.errors[0], /before completing configuration/) + }) + + it('reports the server channel-mismatch message without acknowledging it', () => { + const bot = client() + let code + bot.once('error', error => { code = error.code }) + bot.receive(Buffer.from([6, 0, 0, 0, 0])) + assert.equal(code, 'FORGE_CHANNEL_MISMATCH') + bot.receive(Buffer.from([1, 0])) + bot.state = 'play' + bot.emit('state', 'play') + assert.equal(bot.forgeHandshakeComplete, false) + assert.equal(bot.writes.length, 0) + }) +}) diff --git a/test/forgeHandshakeTest.js b/test/forgeHandshakeTest.js new file mode 100644 index 0000000..78f4205 --- /dev/null +++ b/test/forgeHandshakeTest.js @@ -0,0 +1,62 @@ +'use strict' +/* eslint-env mocha */ + +const assert = require('assert') +const { EventEmitter } = require('events') +const forgeHandshake = require('../src/client/forgeHandshake') + +describe('FML1 initial state', () => { + for (const reset of [false, true]) { + it(`completes the acknowledgment state sequence ${reset ? 'with' : 'without'} an initial reset`, () => { + const client = new EventEmitter() + client.version = '1.12.2' + const writes = [] + client.write = (name, packet) => { if (packet.channel === 'FML|HS') writes.push(packet.data.toString('hex')) } + forgeHandshake(client, { forgeMods: [] }) + const receive = hex => client.emit('custom_payload', { channel: 'FML|HS', data: Buffer.from(hex, 'hex') }) + if (reset) receive('fe') + receive('000200000000') // ServerHello, protocol 2, dimension 0 + receive('0200') // Empty server ModList + receive('0300') // RegistryData state-machine prefix: final registry + receive('ff02') + receive('ff03') + assert.deepStrictEqual(writes, ['0102', '0200', 'ff02', 'ff03', 'ff04', 'ff05']) + assert.equal(client.fmlHandshakeState, 5) + }) + } + + for (const resetBytes of ['fe', 'fe00']) { + it(`restarts a completed handshake on reset ${resetBytes} without skipping registries`, () => { + const client = new EventEmitter() + client.version = '1.12.2' + const writes = [] + client.write = (name, packet) => { if (packet.channel === 'FML|HS') writes.push(packet.data.toString('hex')) } + forgeHandshake(client, { forgeMods: [] }) + const receive = hex => client.emit('custom_payload', { channel: 'FML|HS', data: Buffer.from(hex, 'hex') }) + for (let cycle = 0; cycle < 3; cycle++) { + if (cycle) { + const before = writes.length + receive(resetBytes) + assert.equal(writes.length, before) + assert.equal(client.fmlHandshakeState, 1) + } + receive('000200000000') + receive('0200') + assert.equal(client.fmlHandshakeState, 3) + receive('0300') + receive('ff02') + receive('ff03') + assert.deepStrictEqual(writes.slice(cycle * 6), ['0102', '0200', 'ff02', 'ff03', 'ff04', 'ff05']) + } + }) + } + + it('still rejects an unrelated initial handshake message', () => { + const client = new EventEmitter() + client.write = () => {} + forgeHandshake(client, { forgeMods: [] }) + assert.throws(() => client.emit('custom_payload', { + channel: 'FML|HS', data: Buffer.from('0200', 'hex') + }), /expected ServerHello/) + }) +}) diff --git a/test/neoforgeHandshakeTest.js b/test/neoforgeHandshakeTest.js new file mode 100644 index 0000000..96becd7 --- /dev/null +++ b/test/neoforgeHandshakeTest.js @@ -0,0 +1,182 @@ +/* eslint-env mocha */ +const assert = require('assert') +const { EventEmitter } = require('events') +const { neoforgeHandshake } = require('..') + +function setup (options, version = '1.21.1') { + const client = new EventEmitter() + client.version = version + client.state = 'configuration' + client.writes = [] + client.errors = [] + client.write = (name, data) => client.writes.push({ name, ...data }) + client.on('error', error => client.errors.push(error)) + neoforgeHandshake(client, options) + client.receive = (channel, bytes) => client.emit('custom_payload', { channel, data: Buffer.from(bytes) }) + return client +} + +const string = value => Buffer.concat([Buffer.from([Buffer.byteLength(value)]), Buffer.from(value)]) +function modernSetup (phase, channels) { + return Buffer.concat([Buffer.from([1, phase, channels.length]), ...channels.flatMap(name => [string(name), string(name), string('1')])]) +} + +describe('NeoForge 1.21.1 negotiation', () => { + it('uses modern query framing for 1.21.11 and rejects unverified versions', () => { + const client = setup({}, '1.21.11') + client.receive('neoforge:register', [0]) + assert.deepStrictEqual(client.writes[0].data.subarray(0, 3), Buffer.from([2, 4, 7])) + assert.throws(() => setup({}, '1.20.2'), /Unsupported NeoForge Minecraft version/) + }) + + it('handles only the empty 1.21.11 recipe sync and rejects nonempty or malformed content', () => { + for (const bytes of [[0, 0], [], [0], [0, 0, 1], [1, 0, 0], [0, 1]]) { + const client = setup({}, '1.21.11') + const received = [] + client.on('neoforgeRecipes', recipes => received.push(recipes)) + client.receive('neoforge:register', [0]) + client.receive('neoforge:network', modernSetup(1, ['neoforge:recipe_content'])) + client.state = 'play' + client.emit('state', 'play') + client.receive('neoforge:recipe_content', bytes) + if (bytes.length === 2 && bytes.every(byte => byte === 0)) { + assert.deepStrictEqual(received, [{ recipeTypes: [], recipes: [] }]) + assert.equal(client.errors.length, 0) + } else { + assert.match(client.errors[0].message, /Unsupported NeoForge recipe content/) + assert.equal(received.length, 0) + } + } + }) + + it('allows an explicit recipe implementation to replace the empty-only handler', () => { + const received = [] + const client = setup({ playChannels: { 'neoforge:recipe_content': { version: '1', handler: bytes => received.push(bytes) } } }, '1.21.11') + client.receive('neoforge:register', [0]) + client.receive('neoforge:network', modernSetup(1, ['neoforge:recipe_content'])) + client.state = 'play' + client.receive('neoforge:recipe_content', [42]) + assert.deepStrictEqual(received, [Buffer.from([42])]) + assert.equal(client.errors.length, 0) + }) + + it('uses separate lists and optional channel versions for 1.20.4', () => { + const client = setup({}, '1.20.4') + client.receive('neoforge:register', [0, 0]) + assert.equal(client.writes[0].data[0], 3) + assert.ok(client.writes[0].data.includes(Buffer.from('20.4'))) + client.receive('neoforge:network', [0, 0]) + client.emit('state', 'play') + assert.equal(client.neoforgeHandshakeComplete, true) + assert.equal(client.errors.length, 0) + }) + it('answers the captured empty query and accepts the captured empty setup', () => { + const client = setup() + client.receive('neoforge:register', [0]) + assert.deepStrictEqual(client.writes[0].data.subarray(0, 3), Buffer.from([2, 4, 7])) + assert.ok(client.writes[0].data.includes(Buffer.from('neoforge:extensible_enum_data'))) + client.receive('neoforge:network', [2, 4, 0, 1, 0]) + assert.equal(client.writes[1].channel, 'minecraft:register') + client.emit('state', 'play') + assert.equal(client.neoforgeHandshakeComplete, true) + assert.equal(client.errors.length, 0) + }) + + it('responds to configuration ping with the exact token', () => { + const client = setup() + client.emit('ping', { id: 123 }) + assert.deepStrictEqual(client.writes, [{ name: 'pong', id: 123 }]) + const other = setup({ respondToPing: false }) + other.emit('ping', { id: 123 }) + assert.equal(other.writes.length, 0) + }) + + it('rejects server mismatch without claiming completion', () => { + const client = setup() + client.receive('neoforge:modded_network_setup_failed', [0]) + assert.equal(client.errors[0].code, 'NEOFORGE_CHANNEL_MISMATCH') + client.emit('state', 'play') + assert.equal(client.neoforgeHandshakeComplete, false) + }) + + it('rejects unsolicited setup and truncated queries', () => { + const client = setup() + client.receive('neoforge:network', [0]) + assert.match(client.errors[0].message, /Unexpected/) + const other = setup() + other.receive('neoforge:register', []) + assert.equal(other.errors.length, 1) + assert.equal(other.writes.length, 0) + }) + + it('does not accept setup omitting a required offered channel', () => { + const client = setup({ configurationChannels: { 'example:test': { version: '1', handler: () => {} } } }) + client.receive('neoforge:register', [0]) + client.receive('neoforge:network', [2, 4, 0, 1, 0]) + assert.match(client.errors[0].message, /Missing required/) + }) + + it('requires a handler for incoming advertised channels', () => { + assert.throws(() => setup({ playChannels: { 'example:test': { version: '1' } } }), /require a handler/) + }) + + it('resets negotiation for reconfiguration and rejects premature play', () => { + const client = setup() + client.receive('neoforge:register', [0]) + client.receive('neoforge:network', [2, 4, 0, 1, 0]) + client.emit('state', 'play') + client.emit('state', 'configuration') + assert.equal(client.neoforgeHandshakeComplete, false) + client.emit('state', 'play') + assert.match(client.errors[0].message, /without channel negotiation/) + }) + + it('requires negotiated registry synchronization before marking the handshake complete', () => { + const client = setup() + client.receive('neoforge:register', [0]) + client.receive('neoforge:network', modernSetup(4, ['neoforge:frozen_registry_sync_completed'])) + client.emit('state', 'play') + assert.equal(client.neoforgeHandshakeComplete, false) + assert.match(client.errors[0].message, /registry completion/) + }) + + it('does not acknowledge more traffic after a negotiated handler fails', () => { + const client = setup({ configurationChannels: { 'example:test': { version: '1', handler: () => { throw new Error('invalid mod payload') } } } }) + client.receive('neoforge:register', [0]) + client.receive('neoforge:network', modernSetup(4, ['example:test'])) + const before = client.writes.length + client.receive('example:test', [0]) + client.receive('example:test', [0]) + client.emit('ping', { id: 9 }) + client.emit('state', 'play') + assert.equal(client.errors.length, 1) + assert.match(client.errors[0].message, /invalid mod payload/) + assert.equal(client.writes.length, before) + assert.equal(client.neoforgeHandshakeComplete, false) + }) +}) + +describe('NeoForge compatibility checks', () => { + const checks = require('../src/client/neoforgeChecks') + const string = value => Buffer.concat([Buffer.from([Buffer.byteLength(value)]), Buffer.from(value)]) + + it('acknowledges only unextended enums and empty modded flags', () => { + const client = new EventEmitter() + const writes = [] + client.write = (name, data) => writes.push(data) + const handlers = checks(client) + handlers['neoforge:extensible_enum_data'].handler(Buffer.concat([Buffer.from([1]), string('example.Enum'), string('CLIENTBOUND'), Buffer.from([0])])) + handlers['neoforge:feature_flags'].handler(Buffer.from([0])) + assert.deepStrictEqual(writes.map(packet => packet.channel), ['neoforge:extensible_enum_ack', 'neoforge:feature_flags_ack']) + assert.ok(writes.every(packet => packet.data.length === 0)) + }) + + it('rejects extended enums, modded flags, and trailing bytes without acknowledgments', () => { + const client = new EventEmitter() + client.write = () => assert.fail('must not acknowledge unsupported data') + const handlers = checks(client) + assert.throws(() => handlers['neoforge:extensible_enum_data'].handler(Buffer.concat([Buffer.from([1]), string('example.Enum'), string('CLIENTBOUND'), Buffer.from([1, 1, 2, 1]), string('MODDED')])), /Unsupported/) + assert.throws(() => handlers['neoforge:feature_flags'].handler(Buffer.concat([Buffer.from([1]), string('example:flag')])), /Unsupported/) + assert.throws(() => handlers['neoforge:feature_flags'].handler(Buffer.from([0, 1])), /Trailing/) + }) +}) diff --git a/test/neoforgeRegistriesTest.js b/test/neoforgeRegistriesTest.js new file mode 100644 index 0000000..f2f36e2 --- /dev/null +++ b/test/neoforgeRegistriesTest.js @@ -0,0 +1,77 @@ +/* eslint-env mocha */ +const assert = require('assert') +const { EventEmitter } = require('events') +const registries = require('../src/client/neoforgeRegistries') +const string = value => Buffer.concat([Buffer.from([Buffer.byteLength(value)]), Buffer.from(value)]) +const start = 'neoforge:frozen_registry_sync_start' +const data = 'neoforge:frozen_registry' +const done = 'neoforge:frozen_registry_sync_completed' + +function setup () { + const client = new EventEmitter() + client.version = '1.21.1' + client.writes = [] + client.write = (name, packet) => client.writes.push(packet) + return { client, handlers: registries(client) } +} + +describe('NeoForge frozen registry synchronization', () => { + it('parses the complete live NeoForge command tree using its received registry', () => { + const fixture = require('./fixtures/neoforge-1.21.1-commands.json') + const { createDeserializer } = require('minecraft-protocol') + const minecraftData = require('minecraft-data') + const original = JSON.stringify(minecraftData(fixture.version).protocol) + const { client, handlers } = setup() + handlers[start].handler(Buffer.concat([Buffer.from([1]), string('minecraft:command_argument_type')])) + handlers[data].handler(Buffer.from(fixture.registry, 'hex')) + handlers[done].handler(Buffer.alloc(0)) + const decoder = createDeserializer({ version: fixture.version, state: 'play', isServer: false, customPackets: client.customPackets }).proto + const packet = Buffer.from(fixture.commands, 'hex') + const parsed = decoder.parsePacketBuffer('packet', packet) + assert.equal(parsed.metadata.size, packet.length) + assert.equal(parsed.data.params.rootIndex, 0) + assert.ok(parsed.data.params.nodes.length > 1) + assert.equal(JSON.stringify(minecraftData(fixture.version).protocol), original) + assert.equal(client.neoforgeRegistrySyncComplete, true) + }) + + it('stores IDs and aliases and acknowledges only after the whole list', () => { + const { client, handlers } = setup() + handlers[start].handler(Buffer.concat([Buffer.from([1]), string('example:test')])) + assert.throws(() => handlers[done].handler(Buffer.alloc(0)), /Incomplete/) + handlers[data].handler(Buffer.concat([string('example:test'), Buffer.from([1, 7]), string('example:item'), Buffer.from([1]), string('example:old'), string('example:item')])) + assert.deepStrictEqual(client.neoforgeRegistries.get('example:test'), { name: 'example:test', ids: [{ value: 7, key: 'example:item' }], aliases: [{ key: 'example:old', value: 'example:item' }] }) + assert.equal(client.writes.length, 0) + handlers[done].handler(Buffer.alloc(0)) + assert.deepStrictEqual(client.writes, [{ channel: done, data: Buffer.alloc(0) }]) + assert.throws(() => handlers[done].handler(Buffer.alloc(0)), /Incomplete/) + client.emit('state', 'configuration') + assert.equal(client.neoforgeRegistries.size, 0) + }) + + it('rejects unannounced snapshots, duplicate entries and truncated data', () => { + const { client, handlers } = setup() + const empty = Buffer.concat([string('example:test'), Buffer.from([0, 0])]) + assert.throws(() => handlers[data].handler(empty), /Unexpected/) + handlers[start].handler(Buffer.concat([Buffer.from([1]), string('example:test')])) + assert.throws(() => handlers[data].handler(empty.subarray(0, empty.length - 1))) + const duplicate = Buffer.concat([string('example:test'), Buffer.from([2, 7]), string('example:a'), Buffer.from([7]), string('example:b'), Buffer.from([0])]) + assert.throws(() => handlers[data].handler(duplicate), /Duplicate/) + assert.equal(client.neoforgeRegistries.size, 0) + assert.equal(client.writes.length, 0) + }) + + it('requires a new configuration cycle before restarting completed synchronization', () => { + const { client, handlers } = setup() + handlers[start].handler(Buffer.from([0])) + handlers[done].handler(Buffer.alloc(0)) + assert.equal(client.neoforgeRegistrySyncComplete, true) + assert.throws(() => handlers[start].handler(Buffer.from([0])), /Duplicate/) + client.emit('state', 'configuration') + assert.equal(client.neoforgeRegistrySyncComplete, false) + handlers[start].handler(Buffer.from([0])) + handlers[done].handler(Buffer.alloc(0)) + assert.equal(client.neoforgeRegistrySyncComplete, true) + assert.equal(client.writes.length, 2) + }) +}) diff --git a/test/reconfigurationTest.js b/test/reconfigurationTest.js new file mode 100644 index 0000000..08995da --- /dev/null +++ b/test/reconfigurationTest.js @@ -0,0 +1,98 @@ +/* eslint-env mocha */ +const assert = require('assert') +const { once } = require('events') +const mc = require('minecraft-protocol') +const plugins = require('..') + +// A controlled TCP peer exercises the real client state changes and codecs. +// It is not a Java proxy/backend-transfer compatibility test. +describe('configuration cycles over TCP', function () { + this.timeout(10000) + for (const version of ['1.20.4', '1.21.1', '1.21.11']) { + for (const loader of ['forge', 'neoforge', 'fabric']) { + it(`${loader} ${version} completes three configurations on one connection`, async () => { + const server = new mc.Server(version) + let client + let peer + let cycles = 0 + let transitions = 0 + let configurationAcks = 0 + const errors = [] + const onError = error => errors.push(error) + server.on('error', onError) + server.on('connection', connection => { + peer = connection + peer.on('error', onError) + peer.on('set_protocol', () => { peer.state = 'login' }) + peer.on('login_start', () => peer.write('success', { + uuid: '00000000-0000-0000-0000-000000000001', username: 'CycleTest', properties: [] + })) + let replies = 0 + const payload = (channel, bytes) => peer.write('custom_payload', { channel, data: Buffer.from(bytes) }) + function configure () { + peer.state = 'configuration' + replies = 0 + if (loader === 'forge') { + payload('forge:handshake', [1, 0]) + payload('forge:handshake', [2, 0]) + payload('forge:handshake', [3, cycles + 1, 0, 0]) + } else if (loader === 'neoforge') { + payload('neoforge:register', version === '1.20.4' ? [0, 0] : [0]) + } else { + payload('minecraft:register', Buffer.from('c:version\0c:register')) + payload('c:version', [1, 1]) + payload('c:register', Buffer.concat([Buffer.from([1, 4]), Buffer.from('play'), Buffer.from([0])])) + } + } + peer.on('login_acknowledged', configure) + peer.on('configuration_acknowledged', () => { + configurationAcks++ + configure() + }) + peer.on('custom_payload', packet => { + if (peer.state !== 'configuration') return + if (loader === 'neoforge') { + if (packet.channel === 'neoforge:register') { + payload('neoforge:network', version === '1.20.4' ? [0, 0] : [2, 4, 0, 1, 0]) + } else if (packet.channel === 'minecraft:register') peer.write('finish_configuration', {}) + } else if (++replies === 3) peer.write('finish_configuration', {}) + }) + peer.on('finish_configuration', () => { + cycles++ + peer.state = 'play' + if (cycles < 3) peer.write('start_configuration', {}) + }) + }) + server.listen(0, '127.0.0.1') + try { + await once(server, 'listening') + client = mc.createClient({ host: '127.0.0.1', port: server.socketServer.address().port, version, username: 'CycleTest', auth: 'offline' }) + client.on('error', onError) + if (loader === 'forge') plugins.forgeHandshakeModern(client) + if (loader === 'neoforge') plugins.neoforgeHandshake(client) + if (loader === 'fabric') plugins.fabricNetworking(client) + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(`Incomplete cycles: client ${transitions}, server ${cycles}; ${errors.map(e => e.message).join('; ')}`)), 5000) + client.on('state', state => { + if (state !== 'play') return + transitions++ + if (transitions === 3) { + clearTimeout(timeout) + setTimeout(resolve, 25) + } + }) + }) + assert.deepStrictEqual(errors, []) + assert.equal(cycles, 3) + assert.equal(configurationAcks, 2) + if (loader === 'forge') assert.equal(client.forgeHandshakeComplete, true) + if (loader === 'neoforge') assert.equal(client.neoforgeHandshakeComplete, true) + } finally { + client?.socket?.destroy() + peer?.socket?.destroy() + await new Promise(resolve => server.socketServer.close(resolve)) + } + }) + } + } +})