diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a34d8d..c7da3c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,3 +23,40 @@ jobs: node-version: ${{ matrix.node-version }} - run: npm install - run: npm test + + prepare-e2e-versions: + + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + + steps: + - uses: actions/checkout@v2 + - id: set-matrix + run: echo "matrix=$(node -p "JSON.stringify(require('./test/e2e/testedVersions.json'))")" >> $GITHUB_OUTPUT + + e2e: + + needs: prepare-e2e-versions + runs-on: ubuntu-latest + name: MC ${{ matrix.version }} + + strategy: + fail-fast: false + matrix: + version: ${{ fromJson(needs.prepare-e2e-versions.outputs.matrix) }} + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js 24.x + uses: actions/setup-node@v1 + with: + node-version: 24.x + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + - run: npm install + - run: npm run e2e + env: + MC_VERSION: ${{ matrix.version }} diff --git a/README.md b/README.md index 73184ad..46d9435 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,12 @@ console.log(Item.fromNotch(notchItem)) Take an `Item` instance and returns it in the format of the minecraft packets. - serverAuthoritative: Whether the server is using server authoritative inventory (whether or not to write a Stack ID) +#### Item.toHashedNotch(item) + +Take an `Item` instance and return it as the `HashedSlot` that 1.21.5+ `window_click` packets carry: the item id, count, a CRC32C hash per added component (as the vanilla client computes it) and the removed component types. Returns `null` for an empty slot. Throws on versions whose protocol has no `HashedSlot`. + +Hashes are taken over each component's codec form (the shape a data pack writes), described as protodef types in `lib/hashedSlot.json` on top of the `hash` datatype; `lib/hashedSlot.js` maps the network form onto it. Components whose hash can't be reproduced are sent with hash 0, which makes the server resend that slot after the click. + #### Item.fromNotch(item[, stackId]) Take an `item` in the format of the minecraft packets and return an `Item` instance. diff --git a/index.d.ts b/index.d.ts index 839067f..12f2717 100644 --- a/index.d.ts +++ b/index.d.ts @@ -30,6 +30,7 @@ export class Item { readonly spawnEggMobName: string; static equal(item1: Item, item2: Item, matchStackSize?: boolean, matchNbt?: boolean): boolean; static toNotch(item: ItemLike, serverAuthoritative?: boolean): object; + static toHashedNotch(item: ItemLike | null): object | null; static fromNotch(item: object, stackId?: number): ItemLike; static anvil( itemOne: ItemLike, diff --git a/index.js b/index.js index 47a27b0..76b3661 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,9 @@ const nbt = require('prismarine-nbt') +const hashedSlotLoader = require('./lib/hashedSlot') function loader (registryOrVersion) { const registry = typeof registryOrVersion === 'string' ? require('prismarine-registry')(registryOrVersion) : registryOrVersion + const hashedSlot = registry.type === 'pc' && registry.protocol?.types?.HashedSlot ? hashedSlotLoader(registry) : null class Item { constructor (type, count, metadata, nbt, stackId, sentByServer) { if (type == null) return @@ -80,6 +82,23 @@ function loader (registryOrVersion) { return Item.currentStackId++ } + // 1.21.5+ window_click claims slot contents as HashedSlot: the item id, + // count and a CRC32C per changed component instead of the components + // themselves. A component the hasher can't reproduce is sent with hash 0, + // which just makes the server resend that slot. + static toHashedNotch (item) { + if (!hashedSlot) throw new Error('HashedSlot is not part of this version\'s protocol') + if (!item) return null + return { + itemId: item.type, + itemCount: item.count, + components: item.components + .filter(component => !hashedSlot.NOT_HASHED.has(component.type)) + .map(component => ({ type: component.type, hash: hashedSlot.hashComponent(component.type, component.data) ?? 0 })), + removeComponents: item.removedComponents + } + } + static toNotch (item, serverAuthoritative = true) { const hasNBT = item && item.nbt && Object.keys(item.nbt.value).length > 0 diff --git a/lib/hashOps.js b/lib/hashOps.js new file mode 100644 index 0000000..935133f --- /dev/null +++ b/lib/hashOps.js @@ -0,0 +1,227 @@ +// Vanilla's HashOps (net.minecraft.util.HashOps) as protodef types, named as +// HashOps names them. A value is encoded as a tagged byte stream and CRC32C'd; +// a nested value contributes its hash as 4 little-endian bytes; map entries +// are ordered by (key hash, value hash) as unsigned ints. Numbers and UTF-16 +// code units are little-endian, as Guava's Hasher writes them. +const { ProtoDef } = require('protodef') + +const TAG = { + empty: 1, + mapStart: 2, + mapEnd: 3, + listStart: 4, + listEnd: 5, + byte: 6, + short: 7, + int: 8, + long: 9, + float: 10, + double: 11, + string: 12, + boolean: 13, + byteArrayStart: 14, + byteArrayEnd: 15, + intArrayStart: 16, + intArrayEnd: 17, + longArrayStart: 18, + longArrayEnd: 19 +} +const HASH_SIZE = 4 +const hashed = body => ['hash', { alg: 'crc32c', type: 'lu32', body }] + +function unreadable () { + throw new Error('HashOps values are hashed on write and cannot be read back') +} + +// A tag byte followed by the value as `type` +function scalar (tag, type) { + return [unreadable, function (value, buffer, offset) { + buffer[offset] = tag + return this.write(value, buffer, offset + 1, type) + }, function (value) { + return 1 + this.sizeOf(value, type) + }] +} + +// A start tag, each element as `type`, an end tag +function sequence (start, end, type) { + return [unreadable, function (value, buffer, offset) { + buffer[offset++] = start + for (const element of value) offset = this.write(element, buffer, offset, type) + buffer[offset] = end + return offset + 1 + }, function (value) { + return 2 + value.reduce((size, element) => size + this.sizeOf(element, type), 0) + }] +} + +// entries: [key, keyType, value, valueType][] +function writeMap (entries, buffer, offset, context) { + const pairs = entries.map(([key, keyType, value, valueType]) => { + const pair = Buffer.alloc(2 * HASH_SIZE) + this.write(key, pair, 0, hashed(keyType), context) + this.write(value, pair, HASH_SIZE, hashed(valueType), context) + return pair + }) + pairs.sort((a, b) => (a.readUInt32LE(0) - b.readUInt32LE(0)) || (a.readUInt32LE(HASH_SIZE) - b.readUInt32LE(HASH_SIZE))) + buffer[offset++] = TAG.mapStart + for (const pair of pairs) offset += pair.copy(buffer, offset) + buffer[offset] = TAG.mapEnd + return offset + 1 +} +const sizeOfMap = count => 2 + count * 2 * HASH_SIZE + +function equal (a, b) { + if (a === b) return true + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false + if (Array.isArray(a) !== Array.isArray(b)) return false + const keys = new Set([...Object.keys(a), ...Object.keys(b)].filter(key => a[key] !== undefined || b[key] !== undefined)) + return [...keys].every(key => equal(a[key], b[key])) +} + +// A record's fields as map entries. fields: { name: type | { type, +// optional?, default? } }. A field is left out when it is optional or +// defaulted and absent, or equal to its default, the way a codec omits +// optional fields. +function recordEntries (value, fields) { + const entries = [] + for (const [name, spec] of Object.entries(fields ?? {})) { + const field = typeof spec === 'object' && !Array.isArray(spec) ? spec : { type: spec } + const fieldValue = value == null ? undefined : value[name] + if (fieldValue == null) { + if (field.optional || 'default' in field) continue + throw new Error(`${name} is required`) + } + if ('default' in field && equal(fieldValue, field.default)) continue + entries.push([name, 'string', fieldValue, field.type]) + } + return entries +} + +// Text components arrive as NBT written by NbtOps, which stores booleans as +// bytes; the hash must see them as booleans, and nested components as +// components. A heterogeneous list wraps each element as {"": element}. +const TEXT_BOOLEANS = new Set(['bold', 'italic', 'underlined', 'strikethrough', 'obfuscated', 'interpret']) +function textEntries (tag) { + return Object.entries(tag.value).map(([key, entry]) => { + if (TEXT_BOOLEANS.has(key)) return [key, 'string', entry.value !== 0, 'boolean'] + if (key === 'extra' || key === 'with' || key === 'separator') return [key, 'string', entry, 'text'] + if ((key === 'hover_event' || key === 'click_event') && entry.type === 'compound') return [key, 'string', entry, 'text_event'] + return [key, 'string', entry, 'nbt'] + }) +} +const textEventEntries = tag => Object.entries(tag.value) + .map(([key, entry]) => [key, 'string', entry, key === 'value' || key === 'name' ? 'text' : 'nbt']) +const unwrapped = tag => tag.type === 'compound' && Object.keys(tag.value).length === 1 && '' in tag.value ? tag.value[''] : null +const textList = tag => tag.value.value.map(value => ({ type: tag.value.type, value })) + +const types = { + empty: [unreadable, (value, buffer, offset) => { + buffer[offset] = TAG.empty + return offset + 1 + }, 1], + byte: [unreadable, (value, buffer, offset) => { + buffer[offset] = TAG.byte + buffer[offset + 1] = value + return offset + 2 + }, 2], + short: scalar(TAG.short, 'li16'), + int: scalar(TAG.int, 'li32'), + long: scalar(TAG.long, 'li64'), + float: scalar(TAG.float, 'lf32'), + double: scalar(TAG.double, 'lf64'), + boolean: [unreadable, (value, buffer, offset) => { + buffer[offset] = TAG.boolean + buffer[offset + 1] = value ? 1 : 0 + return offset + 2 + }, 2], + // Length in UTF-16 code units, then the code units + string: [unreadable, (value, buffer, offset) => { + buffer[offset] = TAG.string + buffer.writeInt32LE(value.length, offset + 1) + return offset + 5 + buffer.write(value, offset + 5, 'utf16le') + }, value => 5 + value.length * 2], + byte_array: [unreadable, (value, buffer, offset) => { + buffer[offset++] = TAG.byteArrayStart + for (const byte of value) buffer[offset++] = byte + buffer[offset] = TAG.byteArrayEnd + return offset + 1 + }, value => 2 + value.length], + int_array: sequence(TAG.intArrayStart, TAG.intArrayEnd, 'li32'), + long_array: sequence(TAG.longArrayStart, TAG.longArrayEnd, 'li64'), + + // The hashes of the elements, each as the argument type + list: [unreadable, function (value, buffer, offset, type, context) { + buffer[offset++] = TAG.listStart + for (const element of value) offset = this.write(element, buffer, offset, hashed(type), context) + buffer[offset] = TAG.listEnd + return offset + 1 + }, value => 2 + value.length * HASH_SIZE], + // An object's entries as a map of string keys to the argument type + dict: [unreadable, function (value, buffer, offset, valueType, context) { + return writeMap.call(this, Object.entries(value).map(([k, v]) => [k, 'string', v, valueType]), buffer, offset, context) + }, value => sizeOfMap(Object.keys(value).length)], + // A record as a map; see recordEntries for the fields argument + map: [unreadable, function (value, buffer, offset, fields, context) { + return writeMap.call(this, recordEntries(value, fields), buffer, offset, context) + }, (value, fields) => sizeOfMap(recordEntries(value, fields).length)], + + // A text component as a prismarine-nbt tag + text: [unreadable, function (tag, buffer, offset, typeArgs, context) { + if (tag.type === 'string') return this.write(tag.value, buffer, offset, 'string') + if (tag.type === 'list') return this.write(textList(tag), buffer, offset, ['list', 'text'], context) + if (tag.type !== 'compound') return this.write(tag, buffer, offset, 'nbt', context) + const inner = unwrapped(tag) + if (inner) return this.write(inner, buffer, offset, 'text', context) + return writeMap.call(this, textEntries(tag), buffer, offset, context) + }, function (tag, typeArgs, context) { + if (tag.type === 'string') return this.sizeOf(tag.value, 'string') + if (tag.type === 'list') return 2 + tag.value.value.length * HASH_SIZE + if (tag.type !== 'compound') return this.sizeOf(tag, 'nbt', context) + const inner = unwrapped(tag) + if (inner) return this.sizeOf(inner, 'text', context) + return sizeOfMap(Object.keys(tag.value).length) + }], + text_event: [unreadable, function (tag, buffer, offset, typeArgs, context) { + return writeMap.call(this, textEventEntries(tag), buffer, offset, context) + }, tag => sizeOfMap(Object.keys(tag.value).length)] +} + +// A prismarine-nbt tag: its type picks the encoding of its value +const nbtTypes = { + nbt: ['container', [{ name: 'value', type: 'nbt_value' }]], + nbt_value: ['switch', { + compareTo: 'type', + fields: { + byte: 'byte', + short: 'short', + int: 'int', + long: 'long', + float: 'float', + double: 'double', + string: 'string', + byteArray: 'byte_array', + intArray: 'int_array', + longArray: 'long_array', + list: 'nbt_list', + compound: ['dict', 'nbt'] + } + }], + nbt_list: ['container', [{ name: 'value', type: ['list', 'nbt_value'] }]] +} + +function createProtoDef () { + const proto = new ProtoDef(false) + proto.addTypes(types) + proto.addTypes(nbtTypes) + return proto +} + +// The CRC32C of `value` encoded as `type`, as the signed int the wire carries +function hashValue (proto, value, type) { + const out = Buffer.alloc(HASH_SIZE) + proto.write(value, out, 0, ['hash', { alg: 'crc32c', type: 'i32', body: type }], {}) + return out.readInt32BE(0) +} + +module.exports = { createProtoDef, hashValue } diff --git a/lib/hashedSlot.js b/lib/hashedSlot.js new file mode 100644 index 0000000..ee4fb43 --- /dev/null +++ b/lib/hashedSlot.js @@ -0,0 +1,106 @@ +// Component hashes are taken over the codec form (the shape a data pack +// writes), while the wire carries the network form. lib/hashedSlot.json holds +// each component's codec shape; what remains here turns the network form into +// the codec form where the two differ, mostly registry ids into names. +const { createProtoDef, hashValue } = require('./hashOps') +const shapes = require('./hashedSlot.json') + +const proto = createProtoDef() +proto.addTypes(shapes.types) +proto.addTypes(shapes.components) + +const DYE_COLORS = ['white', 'orange', 'magenta', 'light_blue', 'yellow', 'lime', 'pink', 'gray', 'light_gray', 'cyan', 'purple', 'blue', 'brown', 'green', 'red', 'black'] +const ATTRIBUTE_OPERATIONS = { add: 'add_value', multiply_base: 'add_multiplied_base', multiply_total: 'add_multiplied_total' } +const EQUIPMENT_SLOT_GROUPS = { main_hand: 'mainhand', off_hand: 'offhand' } +// Components with no codec never hash, on any version. +const NOT_HASHED = new Set(['creative_slot_lock', 'map_post_processing', 'additional_trade_cost']) + +function loader (registry) { + const key = name => name.includes(':') ? name : `minecraft:${name}` + const componentTypeNames = registry.protocol.types.SlotComponentType[1].mappings + + // A converter returns undefined when the value can't be put in codec form, + // which makes the whole component unhashable. + const all = values => values.includes(undefined) ? undefined : values + const dyeColor = value => typeof value === 'number' ? DYE_COLORS[value] : value + const filterable = page => ({ raw: page.content, filtered: page.filteredContent }) + const enchantments = data => Object.fromEntries(data.enchantments.map(e => [key(registry.enchantments[e.id].name), e.level])) + + function uuid (str) { + const hex = str.replace(/-/g, '') + return [0, 8, 16, 24].map(i => parseInt(hex.slice(i, i + 8), 16) | 0) + } + + function itemStack (slot) { + if (!slot || slot.itemCount === 0) return undefined + if (slot.components.length || slot.removeComponents.length) return undefined + return { id: key(registry.items[slot.itemId].name), count: slot.itemCount } + } + + function attributeModifier (m) { + const attribute = registry.attributesArray[m.typeId] + if (!attribute) return undefined + return { + type: key(attribute.resource), + id: key(m.name), + amount: m.value, + operation: ATTRIBUTE_OPERATIONS[m.operation], + slot: EQUIPMENT_SLOT_GROUPS[m.slot] ?? m.slot, + display: m.display && { type: m.display.type, value: m.display.type === 'override' ? m.display.component : undefined } + } + } + + const converters = { + entity_data: data => { + if (data.data === undefined) return data // 1.21.5-1.21.9 send the id inside the tag + const entity = registry.entitiesArray[data.type] + if (!entity) return undefined + return { type: 'compound', value: { id: { type: 'string', value: key(entity.name) }, ...data.data.value } } + }, + block_entity_data: data => data.data === undefined ? data : undefined, // 1.21.9+ send a block entity type id, which the registry lacks + enchantments, + stored_enchantments: enchantments, + base_color: dyeColor, + 'wolf/collar': dyeColor, + 'cat/collar': dyeColor, + 'sheep/color': dyeColor, + 'shulker/color': dyeColor, + 'tropical_fish/base_color': dyeColor, + 'tropical_fish/pattern_color': dyeColor, + block_state: data => Object.fromEntries(data.properties.map(p => [p.name, p.value])), + food: data => ({ nutrition: data.nutrition, saturation: data.saturationModifier, can_always_eat: data.canAlwaysEat }), + tooltip_display: data => ({ hide_tooltip: data.hideTooltip, hidden_components: data.hiddenComponents.map(id => key(componentTypeNames[id])) }), + writable_book_content: data => ({ pages: data.pages.map(filterable) }), + written_book_content: data => ({ + ...data, + title: { raw: data.rawTitle, filtered: data.filteredTitle }, + pages: data.pages.map(page => filterable(page)) + }), + charged_projectiles: data => all(data.projectiles.map(itemStack)), + bundle_contents: data => all(data.contents.map(itemStack)), + container: data => all(data.contents + .map((slot, i) => slot.itemCount === 0 ? null : { slot: i, item: itemStack(slot) }) + .filter(Boolean) + .map(entry => entry.item === undefined ? undefined : entry)), + profile: data => data.type !== undefined + ? undefined // resolvable profiles (1.21.9, 26.1+) + : { name: data.name, id: data.uuid == null ? undefined : uuid(data.uuid), properties: data.properties }, + attribute_modifiers: data => all((data.attributes ?? data).map(attributeModifier)) // wrapped in a container from 1.21.11 + } + + // Returns the hash as a signed 32-bit int (the wire type), or undefined + // when the component's value can't be reproduced in codec form. + function hashComponent (type, data) { + if (!(type in shapes.components)) return undefined + const convert = converters[type] + const codec = convert ? convert(data) : data + if (convert && codec === undefined) return undefined + return hashValue(proto, codec, type) + } + + return { hashComponent, hashedTypes: Object.keys(shapes.components), NOT_HASHED } +} + +module.exports = loader +loader.proto = proto +loader.hash = (value, type) => hashValue(proto, value, type) diff --git a/lib/hashedSlot.json b/lib/hashedSlot.json new file mode 100644 index 0000000..02ed2b0 --- /dev/null +++ b/lib/hashedSlot.json @@ -0,0 +1,97 @@ +{ + "types": { + "item_stack": ["map", { "id": "string", "count": "int" }], + "filterable_string": ["map", { "raw": "string", "filtered": { "type": "string", "optional": true } }], + "filterable_text": ["map", { "raw": "text", "filtered": { "type": "text", "optional": true } }], + "profile_property": ["map", { + "name": "string", + "value": "string", + "signature": { "type": "string", "optional": true } + }], + "attribute_modifier": ["map", { + "type": "string", + "id": "string", + "amount": "double", + "operation": "string", + "slot": { "type": "string", "default": "any" }, + "display": { "type": "attribute_modifier_display", "optional": true, "default": { "type": "default" } } + }], + "attribute_modifier_display": ["map", { "type": "string", "value": { "type": "text", "optional": true } }] + }, + "components": { + "custom_data": "nbt", + "map_decorations": "nbt", + "lock": "nbt", + "container_loot": "nbt", + "debug_stick_state": "nbt", + "bucket_entity_data": "nbt", + "recipes": "nbt", + "entity_data": "nbt", + "block_entity_data": "nbt", + "max_stack_size": "int", + "max_damage": "int", + "damage": "int", + "repair_cost": "int", + "dyed_color": "int", + "map_color": "int", + "map_id": "int", + "ominous_bottle_amplifier": "int", + "unbreakable": ["map", {}], + "glider": ["map", {}], + "intangible_projectile": ["map", {}], + "enchantment_glint_override": "boolean", + "potion_duration_scale": "float", + "minimum_attack_charge": "float", + "item_model": "string", + "tooltip_style": "string", + "note_block_sound": "string", + "rarity": "string", + "custom_name": "text", + "item_name": "text", + "lore": ["list", "text"], + "enchantments": ["dict", "int"], + "stored_enchantments": ["dict", "int"], + "base_color": "string", + "wolf/collar": "string", + "cat/collar": "string", + "sheep/color": "string", + "shulker/color": "string", + "tropical_fish/base_color": "string", + "tropical_fish/pattern_color": "string", + "block_state": ["dict", "string"], + "food": ["map", { + "nutrition": "int", + "saturation": "float", + "can_always_eat": { "type": "boolean", "default": false } + }], + "tooltip_display": ["map", { + "hide_tooltip": { "type": "boolean", "default": false }, + "hidden_components": { "type": ["list", "string"], "default": [] } + }], + "custom_model_data": ["map", { + "floats": { "type": ["list", "float"], "default": [] }, + "flags": { "type": ["list", "boolean"], "default": [] }, + "strings": { "type": ["list", "string"], "default": [] }, + "colors": { "type": ["list", "int"], "default": [] } + }], + "writable_book_content": ["map", { + "pages": { "type": ["list", "filterable_string"], "default": [] } + }], + "written_book_content": ["map", { + "title": "filterable_string", + "author": "string", + "generation": { "type": "int", "default": 0 }, + "pages": { "type": ["list", "filterable_text"], "default": [] }, + "resolved": { "type": "boolean", "default": false } + }], + "charged_projectiles": ["list", "item_stack"], + "bundle_contents": ["list", "item_stack"], + "container": ["list", ["map", { "slot": "int", "item": "item_stack" }]], + "profile": ["map", { + "name": { "type": "string", "optional": true }, + "id": { "type": "int_array", "optional": true }, + "properties": { "type": ["list", "profile_property"], "default": [] } + }], + "attribute_modifiers": ["list", "attribute_modifier"] + } +} diff --git a/package.json b/package.json index d8dbddd..48993a4 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "types": "index.d.ts", "scripts": { "test": "mocha --reporter spec --exit", + "e2e": "mocha test/e2e --reporter spec --exit", "pretest": "npm run lint", "fix": "standard --fix", "lint": "standard" @@ -17,6 +18,8 @@ "devDependencies": { "@types/node": "^25.4.0", "expect": "^29.1.2", + "minecraft-protocol": "^1.68.0", + "minecraft-wrap": "^1.10.0", "mocha": "^11.0.1", "prismarine-item": "file:.", "standard": "^17.0.0" @@ -34,6 +37,7 @@ "homepage": "https://github.com/PrismarineJS/prismarine-item#readme", "dependencies": { "prismarine-nbt": "^2.0.0", - "prismarine-registry": "^1.4.0" + "prismarine-registry": "^1.4.0", + "protodef": "^1.20.0" } } diff --git a/test/e2e/hashedSlot.js b/test/e2e/hashedSlot.js new file mode 100644 index 0000000..08aee1d --- /dev/null +++ b/test/e2e/hashedSlot.js @@ -0,0 +1,161 @@ +/* eslint-env mocha */ + +// Joins a real vanilla server, /give's each vector's item, then moves it with +// window_click claims built by Item.toHashedNotch. The server is the oracle: +// it resends any slot whose claimed hash doesn't match its own copy, so a +// quiet server means every hash was accepted. Needs java on the PATH. Run +// with `npm run e2e`; MC_VERSION=1.21.5 restricts the run to one version. + +const expect = require('expect').default +const fs = require('fs') +const os = require('os') +const path = require('path') +const mc = require('minecraft-protocol') +const { WrapServer, LauncherDownload } = require('minecraft-wrap') +const vectors = require('../hashedSlot.vectors.json') +// mineflayer's testedVersions from 1.21.5, when hashed slots arrived. 26.1 +// waits on minecraft-data's nested-Slot decode fix. +const testedVersions = require('./testedVersions.json') + +const USERNAME = 'e2e' +const PORT = 25585 +const root = path.join(os.tmpdir(), 'prismarine-item-e2e') +// A hotbar slot no give reaches: each test gives one item into slot 36 and +// clears it, so this slot is always empty. +const SENTINEL_SLOT = 44 + +const versions = process.env.MC_VERSION ? [process.env.MC_VERSION] : testedVersions +const dataVersion = version => require('prismarine-registry')(version).version.dataVersion + +for (const version of versions) { + describe(`vanilla ${version} accepts our window_click slot claims`, function () { + this.timeout(30 * 1000) + const registry = require('prismarine-registry')(version) + const Item = require('prismarine-item')(registry) + const { hashComponent } = require('../../lib/hashedSlot')(registry) + // Give strings come from the latest vector set at or below this version; + // hashes are recomputed from what the live server sends, so versions + // between vector sets are covered too. + const corpus = vectors[Object.keys(vectors) + .filter(key => dataVersion(key) <= registry.version.dataVersion) + .sort((a, b) => dataVersion(b) - dataVersion(a))[0]] + const jar = path.join(root, `vanilla-${version}.jar`) + const server = new WrapServer(jar, path.join(root, `server-${version}`)) + let client + let stateId = 0 + let resyncs = null // collects slot packets between a click and its round trip + + function nextPacket (names, test = () => true, what = names.join('/')) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => fail(new Error(`timed out waiting for ${what}`)), 10000) + const onPacket = packet => { if (test(packet)) { cleanup(); resolve(packet) } } + const onEnd = reason => fail(new Error(`disconnected waiting for ${what}: ${reason}`)) + const fail = err => { cleanup(); reject(err) } + const cleanup = () => { + clearTimeout(timer) + client.removeListener('end', onEnd) + for (const name of names) client.removeListener(name, onPacket) + } + for (const name of names) client.on(name, onPacket) + client.on('end', onEnd) + }) + } + + function click (slot, slotClaim, cursorClaim) { + client.write('window_click', { + windowId: 0, + stateId, + slot, + mouseButton: 0, + mode: 0, + changedSlots: [{ location: slot, item: slotClaim }], + cursorItem: cursorClaim + }) + } + + // Clicks are processed in receipt order on the server's main thread, and + // a claim the server rejects makes it resend the slot. So a click falsely + // claiming the sentinel slot holds stone forces a resync of that slot, + // and its arrival means any resync an earlier click caused is already + // here -- while proving the server really is checking the hashes. A drag + // end with no drag in progress moves nothing and leaves no drag state + // behind, whatever the cursor holds; cursorClaim must be what the cursor + // truly holds so only the sentinel slot mismatches. + async function roundTrip (cursorClaim = null) { + const echo = nextPacket(['set_slot'], packet => packet.slot === SENTINEL_SLOT, 'sentinel resync') + client.write('window_click', { + windowId: 0, + stateId, + slot: -999, + mouseButton: 2, + mode: 5, + changedSlots: [{ location: SENTINEL_SLOT, item: { itemId: registry.itemsByName.stone.id, itemCount: 1, components: [], removeComponents: [] } }], + cursorItem: cursorClaim + }) + await echo + } + + before(function (done) { + this.timeout(10 * 60 * 1000) + fs.mkdirSync(root, { recursive: true }) + new LauncherDownload(root, 'linux').getServer(version, jar).then(() => { + server.deleteServerData(err => { + if (err) return done(err) + server.startServer({ + 'server-port': PORT, + 'online-mode': 'false', + 'level-type': 'flat', + difficulty: '0', + 'spawn-monsters': 'false', + 'spawn-animals': 'false', + 'spawn-npcs': 'false' + }, err => { + if (err) return done(err) + client = mc.createClient({ host: '127.0.0.1', port: PORT, username: USERNAME, version, auth: 'offline' }) + client.on('error', err => console.error('client error:', err.message)) + client.on('position', packet => client.write('teleport_confirm', { teleportId: packet.teleportId })) + client.on('set_slot', packet => { stateId = packet.stateId; if (packet.slot !== SENTINEL_SLOT) resyncs?.push(`set_slot ${packet.slot}`) }) + client.on('window_items', packet => { stateId = packet.stateId; resyncs?.push('window_items') }) + client.on('set_cursor_item', () => resyncs?.push('set_cursor_item')) + nextPacket(['position'], () => true, 'spawn').then(() => done(), done) + }) + }) + }, done) + }) + + after(function (done) { + client?.end() + server.stopServer(done) + }) + + afterEach(async () => { + resyncs = null + // Closing the inventory returns anything a failed claim left on the + // cursor to a slot, where the clear can reach it. The console runs the + // clear before the next test's give: stdin lines execute in order. + client.write('close_window', { windowId: 0 }) + await roundTrip() + server.writeServer(`clear ${USERNAME}\n`) + }) + + for (const vector of corpus) { + it(vector.give, async function () { + server.writeServer(`give ${USERNAME} ${vector.give}\n`) + const given = await nextPacket(['set_slot'], packet => packet.item.itemCount > 0, `give ${vector.give}`) + // A component the hasher can't reproduce on this version is sent with + // hash 0, which the server rejects by design: a gap, not a bug. + if (given.item.components.some(c => hashComponent(c.type, c.data) === undefined)) this.skip() + const hashed = Item.toHashedNotch(Item.fromNotch(given.item)) + // Pick the item up, then put it back: each click claims what the slot + // and cursor now hold, and the server checks both against its copy. + for (const [slotClaim, cursorClaim] of [[null, hashed], [hashed, null]]) { + resyncs = [] + click(given.slot, slotClaim, cursorClaim) + await roundTrip(cursorClaim) + expect(resyncs).toStrictEqual([]) + resyncs = null + } + }) + } + }) +} diff --git a/test/e2e/testedVersions.json b/test/e2e/testedVersions.json new file mode 100644 index 0000000..fcfc422 --- /dev/null +++ b/test/e2e/testedVersions.json @@ -0,0 +1 @@ +["1.21.5", "1.21.6", "1.21.8", "1.21.9", "1.21.11"] diff --git a/test/hashedSlot.test.js b/test/hashedSlot.test.js new file mode 100644 index 0000000..a7ce10c --- /dev/null +++ b/test/hashedSlot.test.js @@ -0,0 +1,100 @@ +/* eslint-env mocha */ + +const expect = require('expect').default +const nbt = require('prismarine-nbt') +const hashedSlot = require('../lib/hashedSlot') +const { hash } = hashedSlot + +describe('hashed slot', () => { + it('hashes maps independently of insertion order', () => { + const dict = ['dict', 'int'] + expect(hash({ x: 1, y: 2 }, dict)).toBe(hash({ y: 2, x: 1 }, dict)) + expect(hash({ x: 1, y: 2 }, dict)).not.toBe(hash({ x: 2, y: 1 }, dict)) + }) + + it('hashes nbt compounds as maps of their entries', () => { + const tag = nbt.comp({ a: nbt.int(1), b: nbt.string('s') }) + expect(hash(tag, 'nbt')).toBe(hash({ a: 1, b: 's' }, ['map', { a: 'int', b: 'string' }])) + }) + + it('hashes text component booleans as booleans, not bytes', () => { + const tag = nbt.comp({ text: nbt.string('hi'), italic: nbt.byte(0) }) + expect(hash(tag, 'text')).toBe(hash({ text: 'hi', italic: false }, ['map', { text: 'string', italic: 'boolean' }])) + expect(hash(nbt.string('hi'), 'text')).toBe(hash('hi', 'string')) + }) + + it('omits optional and defaulted record fields, and requires the rest', () => { + const record = ['map', { a: 'int', b: { type: 'int', default: 0 }, c: { type: 'int', optional: true } }] + expect(hash({ a: 1, b: 0 }, record)).toBe(hash({ a: 1 }, ['map', { a: 'int' }])) + expect(hash({ a: 1, b: 2, c: 3 }, record)).not.toBe(hash({ a: 1 }, record)) + expect(() => hash({ b: 1 }, record)).toThrow('a is required') + }) + + describe('1.21.5', () => { + const registry = require('prismarine-registry')('1.21.5') + const Item = require('prismarine-item')(registry) + const { hashComponent } = hashedSlot(registry) + + it('hashes enchantments as a map of key to level', () => { + const sharpness = registry.enchantmentsByName.sharpness.id + expect(hashComponent('enchantments', { enchantments: [{ id: sharpness, level: 5 }] })) + .toBe(hash({ 'minecraft:sharpness': 5 }, ['dict', 'int'])) + }) + + it('omits codec defaults', () => { + expect(hashComponent('written_book_content', { rawTitle: 't', filteredTitle: undefined, author: 'a', generation: 0, pages: [], resolved: false })) + .toBe(hash({ title: { raw: 't' }, author: 'a' }, ['map', { title: ['map', { raw: 'string' }], author: 'string' }])) + }) + + it('reports components it cannot hash', () => { + expect(hashComponent('trim', {})).toBeUndefined() + }) + + it('toHashedNotch carries hashes instead of component data', () => { + const item = new Item(registry.itemsByName.diamond_sword.id, 1) + item.components = [{ type: 'damage', data: 3 }, { type: 'unbreakable', data: undefined }] + item.removedComponents = [{ type: 'lore' }] + expect(Item.toHashedNotch(item)).toStrictEqual({ + itemId: registry.itemsByName.diamond_sword.id, + itemCount: 1, + components: [{ type: 'damage', hash: hash(3, 'int') }, { type: 'unbreakable', hash: hash(undefined, ['map', {}]) }], + removeComponents: [{ type: 'lore' }] + }) + expect(Item.toHashedNotch(null)).toBeNull() + }) + }) + + // Component values as a vanilla server sent them, with the hashes it then + // accepted in window_click (no slot resync followed the click). + describe('hashes accepted by a vanilla server', () => { + const vectors = require('./hashedSlot.vectors.json') + for (const version of Object.keys(vectors)) { + describe(version, () => { + const { hashComponent } = hashedSlot(require('prismarine-registry')(version)) + for (const vector of vectors[version]) { + it(vector.give, () => { + for (const component of vector.components) { + expect(hashComponent(component.type, component.data)).toBe(vector.hashes[component.type]) + } + }) + } + }) + } + + // A hasher no vector exercises is only tested against itself. Exempt are + // hashers for components no vectored version has (add a vectors key for + // the version that introduces them). + it('exercises every hasher in at least one vector', () => { + const { hashedTypes } = hashedSlot(require('prismarine-registry')(Object.keys(vectors)[0])) + const tested = new Set(Object.values(vectors).flat().flatMap(vector => vector.components.map(c => c.type))) + const available = new Set(Object.keys(vectors).flatMap(version => + Object.values(require('prismarine-registry')(version).protocol.types.SlotComponentType[1].mappings))) + expect(hashedTypes.filter(type => available.has(type) && !tested.has(type))).toStrictEqual([]) + }) + }) + + it('toHashedNotch throws before 1.21.5', () => { + const Item = require('prismarine-item')('1.21.4') + expect(() => Item.toHashedNotch(null)).toThrow() + }) +}) diff --git a/test/hashedSlot.vectors.json b/test/hashedSlot.vectors.json new file mode 100644 index 0000000..5f00253 --- /dev/null +++ b/test/hashedSlot.vectors.json @@ -0,0 +1,1924 @@ +{ + "1.21.5": [ + { + "give": "diamond_sword[enchantments={sharpness:5,unbreaking:3},damage=10,repair_cost=3]", + "components": [ + { + "type": "enchantments", + "data": { + "enchantments": [ + { + "id": 32, + "level": 5 + }, + { + "id": 39, + "level": 3 + } + ] + } + }, + { + "type": "damage", + "data": 10 + }, + { + "type": "repair_cost", + "data": 3 + } + ], + "hashes": { + "enchantments": 1231404551, + "damage": -919192125, + "repair_cost": -499649379 + } + }, + { + "give": "stone[custom_data={a:1,b:\"x\",c:[I;1,2],d:1.5f,e:[1b,2b],f:{g:2L},h:[{i:1},{i:2}],j:[L;1L,2L],k:[B;1b],l:2s,m:2.5d}]", + "components": [ + { + "type": "custom_data", + "data": { + "type": "compound", + "value": { + "a": { + "type": "int", + "value": 1 + }, + "b": { + "type": "string", + "value": "x" + }, + "c": { + "type": "intArray", + "value": [ + 1, + 2 + ] + }, + "d": { + "type": "float", + "value": 1.5 + }, + "e": { + "type": "list", + "value": { + "type": "byte", + "value": [ + 1, + 2 + ] + } + }, + "f": { + "type": "compound", + "value": { + "g": { + "type": "long", + "value": [ + 0, + 2 + ] + } + } + }, + "h": { + "type": "list", + "value": { + "type": "compound", + "value": [ + { + "i": { + "type": "int", + "value": 1 + } + }, + { + "i": { + "type": "int", + "value": 2 + } + } + ] + } + }, + "j": { + "type": "longArray", + "value": [ + [ + 0, + 1 + ], + [ + 0, + 2 + ] + ] + }, + "k": { + "type": "byteArray", + "value": [ + 1 + ] + }, + "l": { + "type": "short", + "value": 2 + }, + "m": { + "type": "double", + "value": 2.5 + } + } + } + } + ], + "hashes": { + "custom_data": 1675253826 + } + }, + { + "give": "stone[custom_name={text:\"Sw\",italic:false,color:\"red\",bold:true}]", + "components": [ + { + "type": "custom_name", + "data": { + "type": "compound", + "value": { + "color": { + "type": "string", + "value": "red" + }, + "text": { + "type": "string", + "value": "Sw" + }, + "bold": { + "type": "byte", + "value": 1 + }, + "italic": { + "type": "byte", + "value": 0 + } + } + } + } + ], + "hashes": { + "custom_name": 161836921 + } + }, + { + "give": "stone[custom_name=\"plain\",lore=[\"a\",{text:\"b\",bold:true},{text:\"c\",extra:[\"d\",{text:\"e\",italic:true}]}]]", + "components": [ + { + "type": "custom_name", + "data": { + "type": "string", + "value": "plain" + } + }, + { + "type": "lore", + "data": [ + { + "type": "string", + "value": "a" + }, + { + "type": "compound", + "value": { + "text": { + "type": "string", + "value": "b" + }, + "bold": { + "type": "byte", + "value": 1 + } + } + }, + { + "type": "compound", + "value": { + "extra": { + "type": "list", + "value": { + "type": "compound", + "value": [ + { + "": { + "type": "string", + "value": "d" + } + }, + { + "text": { + "type": "string", + "value": "e" + }, + "italic": { + "type": "byte", + "value": 1 + } + } + ] + } + }, + "text": { + "type": "string", + "value": "c" + } + } + } + ] + } + ], + "hashes": { + "custom_name": 1718800843, + "lore": -132831427 + } + }, + { + "give": "stone[custom_name={translate:\"item.minecraft.stone\",with:[\"x\",{text:\"y\"}]}]", + "components": [ + { + "type": "custom_name", + "data": { + "type": "compound", + "value": { + "with": { + "type": "list", + "value": { + "type": "string", + "value": [ + "x", + "y" + ] + } + }, + "translate": { + "type": "string", + "value": "item.minecraft.stone" + } + } + } + } + ], + "hashes": { + "custom_name": 258179657 + } + }, + { + "give": "written_book[written_book_content={title:\"T\",author:\"A\",pages:[\"p1\",{text:\"p2\",bold:true}]}]", + "components": [ + { + "type": "written_book_content", + "data": { + "rawTitle": "T", + "author": "A", + "generation": 0, + "pages": [ + { + "content": { + "type": "string", + "value": "p1" + } + }, + { + "content": { + "type": "compound", + "value": { + "text": { + "type": "string", + "value": "p2" + }, + "bold": { + "type": "byte", + "value": 1 + } + } + } + } + ], + "resolved": false + } + } + ], + "hashes": { + "written_book_content": -816033539 + } + }, + { + "give": "written_book[written_book_content={title:\"T\",author:\"A\",generation:2,resolved:true,pages:[\"p1\"]}]", + "components": [ + { + "type": "written_book_content", + "data": { + "rawTitle": "T", + "author": "A", + "generation": 2, + "pages": [ + { + "content": { + "type": "string", + "value": "p1" + } + } + ], + "resolved": true + } + } + ], + "hashes": { + "written_book_content": -703750909 + } + }, + { + "give": "writable_book[writable_book_content={pages:[\"a\",\"b\"]}]", + "components": [ + { + "type": "writable_book_content", + "data": { + "pages": [ + { + "content": "a" + }, + { + "content": "b" + } + ] + } + } + ], + "hashes": { + "writable_book_content": -1477403382 + } + }, + { + "give": "diamond_pickaxe[unbreakable={}]", + "components": [ + { + "type": "unbreakable" + } + ], + "hashes": { + "unbreakable": -982207288 + } + }, + { + "give": "diamond_pickaxe[max_damage=50,damage=5]", + "components": [ + { + "type": "max_damage", + "data": 50 + }, + { + "type": "damage", + "data": 5 + } + ], + "hashes": { + "max_damage": 20183955, + "damage": 645064431 + } + }, + { + "give": "leather_chestplate[dyed_color=16711680]", + "components": [ + { + "type": "dyed_color", + "data": 16711680 + } + ], + "hashes": { + "dyed_color": 603347239 + } + }, + { + "give": "stone[rarity=epic,max_stack_size=16,item_model=\"minecraft:diamond\",enchantment_glint_override=true,tooltip_style=\"minecraft:x\"]", + "components": [ + { + "type": "rarity", + "data": "epic" + }, + { + "type": "max_stack_size", + "data": 16 + }, + { + "type": "item_model", + "data": "minecraft:diamond" + }, + { + "type": "enchantment_glint_override", + "data": true + }, + { + "type": "tooltip_style", + "data": "minecraft:x" + } + ], + "hashes": { + "rarity": -292715907, + "max_stack_size": 1769065625, + "item_model": 58140582, + "enchantment_glint_override": -1019818302, + "tooltip_style": 829793204 + } + }, + { + "give": "stone[attribute_modifiers=[{type:\"minecraft:armor\",id:\"x:y\",amount:2.5,operation:\"add_value\",slot:\"head\"}]]", + "components": [ + { + "type": "attribute_modifiers", + "data": [ + { + "typeId": 0, + "name": "x:y", + "value": 2.5, + "operation": "add", + "slot": "head" + } + ] + } + ], + "hashes": { + "attribute_modifiers": 1664024130 + } + }, + { + "give": "stone[attribute_modifiers=[{type:\"minecraft:attack_speed\",id:\"x:z\",amount:-1.5,operation:\"add_multiplied_base\"}]]", + "components": [ + { + "type": "attribute_modifiers", + "data": [ + { + "typeId": 4, + "name": "x:z", + "value": -1.5, + "operation": "multiply_base", + "slot": "any" + } + ] + } + ], + "hashes": { + "attribute_modifiers": 910231243 + } + }, + { + "give": "enchanted_book[stored_enchantments={sharpness:1}]", + "components": [ + { + "type": "stored_enchantments", + "data": { + "enchantments": [ + { + "id": 32, + "level": 1 + } + ] + } + } + ], + "hashes": { + "stored_enchantments": -1201233444 + } + }, + { + "give": "stone[tooltip_display={hide_tooltip:true,hidden_components:[\"minecraft:lore\"]}]", + "components": [ + { + "type": "tooltip_display", + "data": { + "hideTooltip": true, + "hiddenComponents": [ + 8 + ] + } + } + ], + "hashes": { + "tooltip_display": 1370755610 + } + }, + { + "give": "cooked_beef[food={nutrition:3,saturation:0.5f,can_always_eat:true}]", + "components": [ + { + "type": "food", + "data": { + "nutrition": 3, + "saturationModifier": 0.5, + "canAlwaysEat": true + } + } + ], + "hashes": { + "food": 1385739095 + } + }, + { + "give": "stone[custom_model_data={floats:[1.5f],strings:[\"s\"],flags:[true],colors:[5]}]", + "components": [ + { + "type": "custom_model_data", + "data": { + "floats": [ + 1.5 + ], + "flags": [ + true + ], + "strings": [ + "s" + ], + "colors": [ + 5 + ] + } + } + ], + "hashes": { + "custom_model_data": 1643691889 + } + }, + { + "give": "oak_sign[block_state={rotation:\"3\"}]", + "components": [ + { + "type": "block_state", + "data": { + "properties": [ + { + "name": "rotation", + "value": "3" + } + ] + } + } + ], + "hashes": { + "block_state": -1872050104 + } + }, + { + "give": "white_banner[base_color=red]", + "components": [ + { + "type": "base_color", + "data": 14 + } + ], + "hashes": { + "base_color": -1939582294 + } + }, + { + "give": "stone[map_id=3,map_color=255,ominous_bottle_amplifier=2]", + "components": [ + { + "type": "map_id", + "data": 3 + }, + { + "type": "map_color", + "data": 255 + }, + { + "type": "ominous_bottle_amplifier", + "data": 2 + } + ], + "hashes": { + "map_id": -499649379, + "map_color": -1474914842, + "ominous_bottle_amplifier": 1064459813 + } + }, + { + "give": "elytra[glider={},intangible_projectile={}]", + "components": [ + { + "type": "intangible_projectile" + } + ], + "hashes": { + "intangible_projectile": -982207288 + } + }, + { + "give": "stone[potion_duration_scale=2.5f]", + "components": [ + { + "type": "potion_duration_scale", + "data": 2.5 + } + ], + "hashes": { + "potion_duration_scale": -825795518 + } + }, + { + "give": "filled_map[map_decorations={a:{type:\"minecraft:player\",x:1.0d,z:2.0d,rotation:3.0f}}]", + "components": [ + { + "type": "map_decorations", + "data": { + "type": "compound", + "value": { + "a": { + "type": "compound", + "value": { + "rotation": { + "type": "float", + "value": 3 + }, + "x": { + "type": "double", + "value": 1 + }, + "z": { + "type": "double", + "value": 2 + }, + "type": { + "type": "string", + "value": "minecraft:player" + } + } + } + } + } + } + ], + "hashes": { + "map_decorations": 1757442925 + } + }, + { + "give": "stone[lock={components:{}}]", + "components": [ + { + "type": "lock", + "data": { + "type": "compound", + "value": {} + } + } + ], + "hashes": { + "lock": -982207288 + } + }, + { + "give": "bundle[bundle_contents=[{id:\"minecraft:stone\",count:3},{id:\"minecraft:dirt\"}]]", + "components": [ + { + "type": "bundle_contents", + "data": { + "contents": [ + { + "itemCount": 3, + "itemId": 1, + "addedComponentCount": 0, + "removedComponentCount": 0, + "components": [], + "removeComponents": [] + }, + { + "itemCount": 1, + "itemId": 28, + "addedComponentCount": 0, + "removedComponentCount": 0, + "components": [], + "removeComponents": [] + } + ] + } + } + ], + "hashes": { + "bundle_contents": -1520863797 + } + }, + { + "give": "crossbow[charged_projectiles=[{id:\"minecraft:arrow\"}]]", + "components": [ + { + "type": "charged_projectiles", + "data": { + "projectiles": [ + { + "itemCount": 1, + "itemId": 842, + "addedComponentCount": 0, + "removedComponentCount": 0, + "components": [], + "removeComponents": [] + } + ] + } + } + ], + "hashes": { + "charged_projectiles": 1004030535 + } + }, + { + "give": "chest[container_loot={loot_table:\"minecraft:chests/simple_dungeon\"}]", + "components": [ + { + "type": "container_loot", + "data": { + "type": "compound", + "value": { + "loot_table": { + "type": "string", + "value": "minecraft:chests/simple_dungeon" + } + } + } + } + ], + "hashes": { + "container_loot": -2040272502 + } + }, + { + "give": "debug_stick[debug_stick_state={\"minecraft:oak_log\":\"axis\"}]", + "components": [ + { + "type": "debug_stick_state", + "data": { + "type": "compound", + "value": { + "minecraft:oak_log": { + "type": "string", + "value": "axis" + } + } + } + } + ], + "hashes": { + "debug_stick_state": -292554263 + } + }, + { + "give": "axolotl_bucket[bucket_entity_data={Health:14f,Variant:2}]", + "components": [ + { + "type": "bucket_entity_data", + "data": { + "type": "compound", + "value": { + "Variant": { + "type": "int", + "value": 2 + }, + "Health": { + "type": "float", + "value": 14 + } + } + } + } + ], + "hashes": { + "bucket_entity_data": -1382229666 + } + }, + { + "give": "knowledge_book[recipes=[\"minecraft:crafting_table\",\"minecraft:oak_planks\"]]", + "components": [ + { + "type": "recipes", + "data": { + "type": "list", + "value": { + "type": "string", + "value": [ + "minecraft:crafting_table", + "minecraft:oak_planks" + ] + } + } + } + ], + "hashes": { + "recipes": -271537018 + } + }, + { + "give": "chest[block_entity_data={id:\"minecraft:chest\",CustomName:\"locked\"}]", + "components": [ + { + "type": "block_entity_data", + "data": { + "type": "compound", + "value": { + "CustomName": { + "type": "string", + "value": "locked" + }, + "id": { + "type": "string", + "value": "minecraft:chest" + } + } + } + } + ], + "hashes": { + "block_entity_data": 1780057539 + } + }, + { + "give": "stone[glider={}]", + "components": [ + { + "type": "glider" + } + ], + "hashes": { + "glider": -982207288 + } + }, + { + "give": "player_head[note_block_sound=\"minecraft:block.note_block.bell\"]", + "components": [ + { + "type": "note_block_sound", + "data": "minecraft:block.note_block.bell" + } + ], + "hashes": { + "note_block_sound": -1881406566 + } + }, + { + "give": "stone[item_name={text:\"n\",color:\"blue\"}]", + "components": [ + { + "type": "item_name", + "data": { + "type": "compound", + "value": { + "color": { + "type": "string", + "value": "blue" + }, + "text": { + "type": "string", + "value": "n" + } + } + } + } + ], + "hashes": { + "item_name": -1566961137 + } + }, + { + "give": "wolf_spawn_egg[wolf/collar=blue]", + "components": [ + { + "type": "wolf/collar", + "data": 11 + } + ], + "hashes": { + "wolf/collar": 199633461 + } + }, + { + "give": "cat_spawn_egg[cat/collar=lime]", + "components": [ + { + "type": "cat/collar", + "data": 5 + } + ], + "hashes": { + "cat/collar": -1201809287 + } + }, + { + "give": "sheep_spawn_egg[sheep/color=pink]", + "components": [ + { + "type": "sheep/color", + "data": 6 + } + ], + "hashes": { + "sheep/color": -906736439 + } + }, + { + "give": "shulker_spawn_egg[shulker/color=cyan]", + "components": [ + { + "type": "shulker/color", + "data": 9 + } + ], + "hashes": { + "shulker/color": -458829651 + } + }, + { + "give": "tropical_fish_bucket[tropical_fish/base_color=orange,tropical_fish/pattern_color=purple]", + "components": [ + { + "type": "tropical_fish/base_color", + "data": 1 + }, + { + "type": "tropical_fish/pattern_color", + "data": 10 + } + ], + "hashes": { + "tropical_fish/base_color": 1688618917, + "tropical_fish/pattern_color": 484716633 + } + }, + { + "give": "player_head[profile={name:\"e2e\",id:[I;1,2,3,4],properties:[{name:\"textures\",value:\"e30=\"}]}]", + "components": [ + { + "type": "profile", + "data": { + "name": "e2e", + "uuid": "00000001-0000-0002-0000-000300000004", + "properties": [ + { + "name": "textures", + "value": "e30=" + } + ] + } + } + ], + "hashes": { + "profile": 988113531 + } + } + ], + "1.21.11": [ + { + "give": "diamond_sword[enchantments={sharpness:5,unbreaking:3},damage=10,repair_cost=3]", + "components": [ + { + "type": "enchantments", + "data": { + "enchantments": [ + { + "id": 33, + "level": 5 + }, + { + "id": 40, + "level": 3 + } + ] + } + }, + { + "type": "damage", + "data": 10 + }, + { + "type": "repair_cost", + "data": 3 + } + ], + "hashes": { + "enchantments": 1231404551, + "damage": -919192125, + "repair_cost": -499649379 + } + }, + { + "give": "stone[custom_data={a:1,b:\"x\",c:[I;1,2],d:1.5f,e:[1b,2b],f:{g:2L},h:[{i:1},{i:2}],j:[L;1L,2L],k:[B;1b],l:2s,m:2.5d}]", + "components": [ + { + "type": "custom_data", + "data": { + "type": "compound", + "value": { + "a": { + "type": "int", + "value": 1 + }, + "b": { + "type": "string", + "value": "x" + }, + "c": { + "type": "intArray", + "value": [ + 1, + 2 + ] + }, + "d": { + "type": "float", + "value": 1.5 + }, + "e": { + "type": "list", + "value": { + "type": "byte", + "value": [ + 1, + 2 + ] + } + }, + "f": { + "type": "compound", + "value": { + "g": { + "type": "long", + "value": [ + 0, + 2 + ] + } + } + }, + "h": { + "type": "list", + "value": { + "type": "compound", + "value": [ + { + "i": { + "type": "int", + "value": 1 + } + }, + { + "i": { + "type": "int", + "value": 2 + } + } + ] + } + }, + "j": { + "type": "longArray", + "value": [ + [ + 0, + 1 + ], + [ + 0, + 2 + ] + ] + }, + "k": { + "type": "byteArray", + "value": [ + 1 + ] + }, + "l": { + "type": "short", + "value": 2 + }, + "m": { + "type": "double", + "value": 2.5 + } + } + } + } + ], + "hashes": { + "custom_data": 1675253826 + } + }, + { + "give": "stone[custom_name={text:\"Sw\",italic:false,color:\"red\",bold:true}]", + "components": [ + { + "type": "custom_name", + "data": { + "type": "compound", + "value": { + "color": { + "type": "string", + "value": "red" + }, + "text": { + "type": "string", + "value": "Sw" + }, + "bold": { + "type": "byte", + "value": 1 + }, + "italic": { + "type": "byte", + "value": 0 + } + } + } + } + ], + "hashes": { + "custom_name": 161836921 + } + }, + { + "give": "stone[custom_name=\"plain\",lore=[\"a\",{text:\"b\",bold:true},{text:\"c\",extra:[\"d\",{text:\"e\",italic:true}]}]]", + "components": [ + { + "type": "custom_name", + "data": { + "type": "string", + "value": "plain" + } + }, + { + "type": "lore", + "data": [ + { + "type": "string", + "value": "a" + }, + { + "type": "compound", + "value": { + "text": { + "type": "string", + "value": "b" + }, + "bold": { + "type": "byte", + "value": 1 + } + } + }, + { + "type": "compound", + "value": { + "extra": { + "type": "list", + "value": { + "type": "compound", + "value": [ + { + "": { + "type": "string", + "value": "d" + } + }, + { + "text": { + "type": "string", + "value": "e" + }, + "italic": { + "type": "byte", + "value": 1 + } + } + ] + } + }, + "text": { + "type": "string", + "value": "c" + } + } + } + ] + } + ], + "hashes": { + "custom_name": 1718800843, + "lore": -132831427 + } + }, + { + "give": "stone[custom_name={translate:\"item.minecraft.stone\",with:[\"x\",{text:\"y\"}]}]", + "components": [ + { + "type": "custom_name", + "data": { + "type": "compound", + "value": { + "with": { + "type": "list", + "value": { + "type": "string", + "value": [ + "x", + "y" + ] + } + }, + "translate": { + "type": "string", + "value": "item.minecraft.stone" + } + } + } + } + ], + "hashes": { + "custom_name": 258179657 + } + }, + { + "give": "written_book[written_book_content={title:\"T\",author:\"A\",pages:[\"p1\",{text:\"p2\",bold:true}]}]", + "components": [ + { + "type": "written_book_content", + "data": { + "rawTitle": "T", + "author": "A", + "generation": 0, + "pages": [ + { + "content": { + "type": "string", + "value": "p1" + } + }, + { + "content": { + "type": "compound", + "value": { + "text": { + "type": "string", + "value": "p2" + }, + "bold": { + "type": "byte", + "value": 1 + } + } + } + } + ], + "resolved": false + } + } + ], + "hashes": { + "written_book_content": -816033539 + } + }, + { + "give": "written_book[written_book_content={title:\"T\",author:\"A\",generation:2,resolved:true,pages:[\"p1\"]}]", + "components": [ + { + "type": "written_book_content", + "data": { + "rawTitle": "T", + "author": "A", + "generation": 2, + "pages": [ + { + "content": { + "type": "string", + "value": "p1" + } + } + ], + "resolved": true + } + } + ], + "hashes": { + "written_book_content": -703750909 + } + }, + { + "give": "writable_book[writable_book_content={pages:[\"a\",\"b\"]}]", + "components": [ + { + "type": "writable_book_content", + "data": { + "pages": [ + { + "content": "a" + }, + { + "content": "b" + } + ] + } + } + ], + "hashes": { + "writable_book_content": -1477403382 + } + }, + { + "give": "diamond_pickaxe[unbreakable={}]", + "components": [ + { + "type": "unbreakable" + } + ], + "hashes": { + "unbreakable": -982207288 + } + }, + { + "give": "diamond_pickaxe[max_damage=50,damage=5]", + "components": [ + { + "type": "max_damage", + "data": 50 + }, + { + "type": "damage", + "data": 5 + } + ], + "hashes": { + "max_damage": 20183955, + "damage": 645064431 + } + }, + { + "give": "leather_chestplate[dyed_color=16711680]", + "components": [ + { + "type": "dyed_color", + "data": 16711680 + } + ], + "hashes": { + "dyed_color": 603347239 + } + }, + { + "give": "stone[rarity=epic,max_stack_size=16,item_model=\"minecraft:diamond\",enchantment_glint_override=true,tooltip_style=\"minecraft:x\"]", + "components": [ + { + "type": "rarity", + "data": "epic" + }, + { + "type": "max_stack_size", + "data": 16 + }, + { + "type": "item_model", + "data": "minecraft:diamond" + }, + { + "type": "enchantment_glint_override", + "data": true + }, + { + "type": "tooltip_style", + "data": "minecraft:x" + } + ], + "hashes": { + "rarity": -292715907, + "max_stack_size": 1769065625, + "item_model": 58140582, + "enchantment_glint_override": -1019818302, + "tooltip_style": 829793204 + } + }, + { + "give": "stone[attribute_modifiers=[{type:\"minecraft:armor\",id:\"x:y\",amount:2.5,operation:\"add_value\",slot:\"head\"}]]", + "components": [ + { + "type": "attribute_modifiers", + "data": { + "attributes": [ + { + "typeId": 0, + "name": "x:y", + "value": 2.5, + "operation": "add", + "slot": "head" + } + ], + "display": { + "type": "default" + } + } + } + ], + "hashes": { + "attribute_modifiers": 1664024130 + } + }, + { + "give": "stone[attribute_modifiers=[{type:\"minecraft:attack_speed\",id:\"x:z\",amount:-1.5,operation:\"add_multiplied_base\"}]]", + "components": [ + { + "type": "attribute_modifiers", + "data": { + "attributes": [ + { + "typeId": 4, + "name": "x:z", + "value": -1.5, + "operation": "multiply_base", + "slot": "any" + } + ], + "display": { + "type": "default" + } + } + } + ], + "hashes": { + "attribute_modifiers": 910231243 + } + }, + { + "give": "enchanted_book[stored_enchantments={sharpness:1}]", + "components": [ + { + "type": "stored_enchantments", + "data": { + "enchantments": [ + { + "id": 33, + "level": 1 + } + ] + } + } + ], + "hashes": { + "stored_enchantments": -1201233444 + } + }, + { + "give": "stone[tooltip_display={hide_tooltip:true,hidden_components:[\"minecraft:lore\"]}]", + "components": [ + { + "type": "tooltip_display", + "data": { + "hideTooltip": true, + "hiddenComponents": [ + 11 + ] + } + } + ], + "hashes": { + "tooltip_display": 1370755610 + } + }, + { + "give": "cooked_beef[food={nutrition:3,saturation:0.5f,can_always_eat:true}]", + "components": [ + { + "type": "food", + "data": { + "nutrition": 3, + "saturationModifier": 0.5, + "canAlwaysEat": true + } + } + ], + "hashes": { + "food": 1385739095 + } + }, + { + "give": "stone[custom_model_data={floats:[1.5f],strings:[\"s\"],flags:[true],colors:[5]}]", + "components": [ + { + "type": "custom_model_data", + "data": { + "floats": [ + 1.5 + ], + "flags": [ + true + ], + "strings": [ + "s" + ], + "colors": [ + 5 + ] + } + } + ], + "hashes": { + "custom_model_data": 1643691889 + } + }, + { + "give": "oak_sign[block_state={rotation:\"3\"}]", + "components": [ + { + "type": "block_state", + "data": { + "properties": [ + { + "name": "rotation", + "value": "3" + } + ] + } + } + ], + "hashes": { + "block_state": -1872050104 + } + }, + { + "give": "white_banner[base_color=red]", + "components": [ + { + "type": "base_color", + "data": 14 + } + ], + "hashes": { + "base_color": -1939582294 + } + }, + { + "give": "pig_spawn_egg[entity_data={id:\"minecraft:pig\",CustomName:\"x\",Health:5f}]", + "components": [ + { + "type": "entity_data", + "data": { + "type": 100, + "data": { + "type": "compound", + "value": { + "CustomName": { + "type": "string", + "value": "x" + }, + "Health": { + "type": "float", + "value": 5 + } + } + } + } + } + ], + "hashes": { + "entity_data": 1738651830 + } + }, + { + "give": "stone[map_id=3,map_color=255,ominous_bottle_amplifier=2]", + "components": [ + { + "type": "map_id", + "data": 3 + }, + { + "type": "map_color", + "data": 255 + }, + { + "type": "ominous_bottle_amplifier", + "data": 2 + } + ], + "hashes": { + "map_id": -499649379, + "map_color": -1474914842, + "ominous_bottle_amplifier": 1064459813 + } + }, + { + "give": "elytra[glider={},intangible_projectile={}]", + "components": [ + { + "type": "intangible_projectile" + } + ], + "hashes": { + "intangible_projectile": -982207288 + } + }, + { + "give": "stone[potion_duration_scale=2.5f]", + "components": [ + { + "type": "potion_duration_scale", + "data": 2.5 + } + ], + "hashes": { + "potion_duration_scale": -825795518 + } + }, + { + "give": "filled_map[map_decorations={a:{type:\"minecraft:player\",x:1.0d,z:2.0d,rotation:3.0f}}]", + "components": [ + { + "type": "map_decorations", + "data": { + "type": "compound", + "value": { + "a": { + "type": "compound", + "value": { + "rotation": { + "type": "float", + "value": 3 + }, + "x": { + "type": "double", + "value": 1 + }, + "z": { + "type": "double", + "value": 2 + }, + "type": { + "type": "string", + "value": "minecraft:player" + } + } + } + } + } + } + ], + "hashes": { + "map_decorations": 1757442925 + } + }, + { + "give": "stone[lock={components:{}}]", + "components": [ + { + "type": "lock", + "data": { + "type": "compound", + "value": {} + } + } + ], + "hashes": { + "lock": -982207288 + } + }, + { + "give": "bundle[bundle_contents=[{id:\"minecraft:stone\",count:3},{id:\"minecraft:dirt\"}]]", + "components": [ + { + "type": "bundle_contents", + "data": { + "contents": [ + { + "itemCount": 3, + "itemId": 1, + "addedComponentCount": 0, + "removedComponentCount": 0, + "components": [], + "removeComponents": [] + }, + { + "itemCount": 1, + "itemId": 28, + "addedComponentCount": 0, + "removedComponentCount": 0, + "components": [], + "removeComponents": [] + } + ] + } + } + ], + "hashes": { + "bundle_contents": -1520863797 + } + }, + { + "give": "crossbow[charged_projectiles=[{id:\"minecraft:arrow\"}]]", + "components": [ + { + "type": "charged_projectiles", + "data": { + "projectiles": [ + { + "itemCount": 1, + "itemId": 895, + "addedComponentCount": 0, + "removedComponentCount": 0, + "components": [], + "removeComponents": [] + } + ] + } + } + ], + "hashes": { + "charged_projectiles": 1004030535 + } + }, + { + "give": "shulker_box[container=[{slot:0,item:{id:\"minecraft:stone\",count:2}},{slot:3,item:{id:\"minecraft:dirt\"}}]]", + "components": [ + { + "type": "container", + "data": { + "contents": [ + { + "itemCount": 2, + "itemId": 1, + "addedComponentCount": 0, + "removedComponentCount": 0, + "components": [], + "removeComponents": [] + }, + { + "itemCount": 0 + }, + { + "itemCount": 0 + }, + { + "itemCount": 1, + "itemId": 28, + "addedComponentCount": 0, + "removedComponentCount": 0, + "components": [], + "removeComponents": [] + } + ] + } + } + ], + "hashes": { + "container": -808115028 + } + }, + { + "give": "white_banner[base_color=red]", + "components": [ + { + "type": "base_color", + "data": 14 + } + ], + "hashes": { + "base_color": -1939582294 + } + }, + { + "give": "pig_spawn_egg[entity_data={id:\"minecraft:pig\",CustomName:\"x\",Health:5f}]", + "components": [ + { + "type": "entity_data", + "data": { + "type": 100, + "data": { + "type": "compound", + "value": { + "CustomName": { + "type": "string", + "value": "x" + }, + "Health": { + "type": "float", + "value": 5 + } + } + } + } + } + ], + "hashes": { + "entity_data": 1738651830 + } + }, + { + "give": "stone[map_id=3,map_color=255,ominous_bottle_amplifier=2]", + "components": [ + { + "type": "map_id", + "data": 3 + }, + { + "type": "map_color", + "data": 255 + }, + { + "type": "ominous_bottle_amplifier", + "data": 2 + } + ], + "hashes": { + "map_id": -499649379, + "map_color": -1474914842, + "ominous_bottle_amplifier": 1064459813 + } + }, + { + "give": "elytra[glider={},intangible_projectile={}]", + "components": [ + { + "type": "intangible_projectile" + } + ], + "hashes": { + "intangible_projectile": -982207288 + } + }, + { + "give": "stone[potion_duration_scale=2.5f]", + "components": [ + { + "type": "potion_duration_scale", + "data": 2.5 + } + ], + "hashes": { + "potion_duration_scale": -825795518 + } + }, + { + "give": "filled_map[map_decorations={a:{type:\"minecraft:player\",x:1.0d,z:2.0d,rotation:3.0f}}]", + "components": [ + { + "type": "map_decorations", + "data": { + "type": "compound", + "value": { + "a": { + "type": "compound", + "value": { + "rotation": { + "type": "float", + "value": 3 + }, + "x": { + "type": "double", + "value": 1 + }, + "z": { + "type": "double", + "value": 2 + }, + "type": { + "type": "string", + "value": "minecraft:player" + } + } + } + } + } + } + ], + "hashes": { + "map_decorations": 1757442925 + } + }, + { + "give": "stone[lock={components:{}}]", + "components": [ + { + "type": "lock", + "data": { + "type": "compound", + "value": {} + } + } + ], + "hashes": { + "lock": -982207288 + } + }, + { + "give": "stone[minimum_attack_charge=0.5f]", + "components": [ + { + "type": "minimum_attack_charge", + "data": 0.5 + } + ], + "hashes": { + "minimum_attack_charge": -1631070359 + } + } + ] +}