Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
29 changes: 18 additions & 11 deletions src/client/autoVersionForge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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)
})
}
80 changes: 80 additions & 0 deletions src/client/commandRegistry.js
Original file line number Diff line number Diff line change
@@ -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 }
47 changes: 47 additions & 0 deletions src/client/data/fabric.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
]
]
}
}
Loading
Loading