From ae44478b30d890fe0fb04022f44d474dcdcc3f9d Mon Sep 17 00:00:00 2001 From: Lassi Pulkkinen Date: Thu, 31 Oct 2024 03:11:21 +0200 Subject: Initial commit (import old repo) --- proto.ha | 715 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 715 insertions(+) create mode 100644 proto.ha (limited to 'proto.ha') diff --git a/proto.ha b/proto.ha new file mode 100644 index 0000000..01e0d33 --- /dev/null +++ b/proto.ha @@ -0,0 +1,715 @@ +use comparray; +use endian; +use mcproto; +use nbt; +use fmt; +use strings; +use trace; + +fn decode_position(ctx: *mcproto::Context) (BlockPos | trace::failed) = { + const v = mcproto::decode_long(ctx)?: i64; + return BlockPos { + x = (v >> 38): i32, + y = (v << 52 >> 52): i16, + z = (v << 26 >> 38): i32, + }; +}; + +fn decode_nbt(ctx: *mcproto::Context) ((str, nbt::Object) | trace::failed) = { + let in = ctx.dec.input[ctx.dec.pos..]; + match (nbt::load(&in)) { + case let res: (str, nbt::Object) => + ctx.dec.pos += len(ctx.dec.input) - ctx.dec.pos - len(in); + return res; + case nbt::Invalid => + return mcproto::error(ctx, "Invalid NBT"); + }; +}; + +type Handler = fn(ctx: *mcproto::Context) + (void | trace::failed); + +fn game_handle_packet(ctx: *mcproto::Context, packet_id: i32) + (void | trace::failed) = { + const handler = if (packet_id: size < len(PROTO_HANDLERS)) + PROTO_HANDLERS[packet_id: size] else null; + match (handler) { + case let handler: *Handler => + const ctx_ = mcproto::context(ctx, "0x{:02x}", packet_id); + return handler(&ctx_); + case null => + // TODO: implement all the packets ._. + // erroring here would spam too much for now... + void; + }; +}; + +const PROTO_HANDLERS: [_]nullable *Handler = [ + null, // 0x00 + null, + null, + null, + null, + null, + null, + null, + null, + &handle_block_update, + null, + null, + null, + null, + null, + null, + null, // 0x10 + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + &handle_unload_chunk, + null, + null, + null, + null, // handle_keep_alive; handled in client_handle_packet. + &handle_chunk_data_and_update_light, + null, + null, + null, + null, // handle_login; handled in client_handle_packet. + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, // 0x30 + null, + null, + null, + &handle_combat_death, + null, + null, + null, + &handle_synchronize_player_position, + null, + null, + null, + null, + &handle_respawn, + null, + &handle_update_section_blocks, + null, // 0x40 + null, + null, + null, + null, + null, + null, + null, + null, + null, + &handle_set_center_chunk, + null, + &handle_set_default_spawn_position, + null, + null, + null, + null, // 0x50 + null, + null, + &handle_set_health, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, // 0x60 + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, +]; + +fn handle_login(ctx: *mcproto::Context) (void | trace::failed) = { + const ctx_ = mcproto::context(ctx, "player id"); + const player_id = mcproto::decode_int(&ctx_)?; + const ctx_ = mcproto::context(ctx, "is hardcore"); + const is_hardcore = mcproto::decode_bool(&ctx_)?; + const ctx_ = mcproto::context(ctx, "gamemode"); + const gamemode = mcproto::decode_byte(&ctx_)?: Gamemode; + if (gamemode >= Gamemode::COUNT) { + return mcproto::error(&ctx_, "Invalid gamemode 0x{:02x}", + gamemode: u8); + }; + const ctx_ = mcproto::context(ctx, "previous gamemode"); + const prev_gamemode = mcproto::decode_byte(&ctx_)?; + const ctx_ = mcproto::context(ctx, "dimension count"); + const dim_count = mcproto::decode_varint(&ctx_)?; + for (let i = 0i32; i < dim_count; i += 1) { + const ctx_ = mcproto::context(ctx, "dimension name {}", i); + const dim_name = mcproto::decode_string(&ctx_, 0x7fff)?; + }; + const ctx_ = mcproto::context(ctx, "registry"); + const registry = decode_nbt(&ctx_)?; + free(registry.0); + const registry = registry.1; + defer nbt::finish(registry); + const ctx_ = mcproto::context(ctx, "dimension type"); + const dim_type = mcproto::decode_string(&ctx_, 0x7fff)?; + const ctx_ = mcproto::context(ctx, "dimension name"); + const dim_name = mcproto::decode_string(&ctx_, 0x7fff)?; + const ctx_ = mcproto::context(ctx, "hashed seed"); + const hashed_seed = mcproto::decode_long(&ctx_)?; + const ctx_ = mcproto::context(ctx, "max players"); + const max_players = mcproto::decode_varint(&ctx_)?; + if (max_players < 0) { + return mcproto::error(&ctx_, "Max players must not be negative"); + }; + const ctx_ = mcproto::context(ctx, "view distance"); + const view_distance = mcproto::decode_varint(&ctx_)?; + if (view_distance <= 0) { + return mcproto::error(&ctx_, "View distance must be positive"); + }; + const ctx_ = mcproto::context(ctx, "simulation distance"); + const sim_distance = mcproto::decode_varint(&ctx_)?; + if (sim_distance <= 0) { + return mcproto::error(&ctx_, + "Simulation distance must be positive"); + }; + const ctx_ = mcproto::context(ctx, "reduced debug info"); + const reduced_debug_info = mcproto::decode_bool(&ctx_)?; + const ctx_ = mcproto::context(ctx, "enable respawn screen"); + const enable_respawn_screen = mcproto::decode_bool(&ctx_)?; + const ctx_ = mcproto::context(ctx, "is debug"); + const is_debug = mcproto::decode_bool(&ctx_)?; + const ctx_ = mcproto::context(ctx, "is flat"); + const is_flat = mcproto::decode_bool(&ctx_)?; + const ctx_ = mcproto::context(ctx, "has death location"); + if (mcproto::decode_bool(&ctx_)?) { + const ctx_ = mcproto::context(ctx, "death dimension"); + const death_dim = mcproto::decode_string(&ctx_, 0x7fff)?; + const ctx_ = mcproto::context(ctx, "death position"); + const death_pos = decode_position(&ctx_)?; + }; + mcproto::expect_end(ctx)?; + + GAMEMODE = gamemode; + trace::info(&trace::root, "gamemode {}", strgamemode(GAMEMODE)); + + assert(len(DIM_TYPES) == 0); + match (nbt_get(®istry, "minecraft:dimension_type")) { + case let reg: nbt::Object => + match (nbt_get(®, "value")) { + case let reg: []nbt::Value => + for (let i = 0z; i < len(reg); i += 1) { + const entry = match (reg[i]) { + case let entry: nbt::Object => + yield entry; + case => + return mcproto::error(ctx, "Invalid registry"); + }; + const name = match (nbt_get(&entry, "name")) { + case let name: str => + yield strings::dup(name); + case => + return mcproto::error(ctx, "Invalid registry"); + }; + const elem = match (nbt_get(&entry, "element")) { + case let elem: nbt::Object => + yield elem; + case => + return mcproto::error(ctx, "Invalid registry"); + }; + const min_y = match (nbt_get(&elem, "min_y")) { + case let min_y: i32 => + if (min_y & 0xf != 0) + return mcproto::error(ctx, "Invalid registry"); + min_y >>= 4; + if (min_y: i8 != min_y) + return mcproto::error(ctx, "Invalid registry"); + yield min_y: i8; + case => + return mcproto::error(ctx, "Invalid registry"); + }; + const height = match (nbt_get(&elem, "height")) { + case let height: i32 => + if (height & 0xf != 0) + return mcproto::error(ctx, "Invalid registry"); + height >>= 4; + if (height: u8 != height: u32) + return mcproto::error(ctx, "Invalid registry"); + yield height: u8; + case => + return mcproto::error(ctx, "Invalid registry"); + }; + if (min_y + height: i8 < min_y) { + return mcproto::error(ctx, "Invalid registry"); + }; + append(DIM_TYPES, DimType { + name = name, + min_y = min_y, + height = height, + }); + }; + case => + return mcproto::error(ctx, "Invalid registry"); + }; + case => + return mcproto::error(ctx, "Invalid registry"); + }; + + let found = false; + for (let i = 0z; i < len(DIM_TYPES); i += 1) { + if (DIM_TYPES[i].name == dim_type) { + found = true; + DIM_TYPE = i; + }; + }; + if (!found) { + return mcproto::error(ctx, "Unknown dimension type {}", dim_type); + }; + trace::info(&trace::root, "dimension type {}", DIM_TYPES[DIM_TYPE].name); + + VIEW_DISTANCE = view_distance; + trace::info(&trace::root, "view distance {}", VIEW_DISTANCE); + + game_init(); +}; + +fn handle_respawn(ctx: *mcproto::Context) (void | trace::failed) = { + const ctx_ = mcproto::context(ctx, "dimension type"); + const dim_type = mcproto::decode_string(&ctx_, 0x7fff)?; + const ctx_ = mcproto::context(ctx, "dimension name"); + const dim_name = mcproto::decode_string(&ctx_, 0x7fff)?; + const ctx_ = mcproto::context(ctx, "hashed seed"); + const hashed_seed = mcproto::decode_long(&ctx_)?; + const ctx_ = mcproto::context(ctx, "gamemode"); + const gamemode = mcproto::decode_byte(&ctx_)?: Gamemode; + if (gamemode >= Gamemode::COUNT) { + return mcproto::error(&ctx_, "Invalid gamemode 0x{:02x}", + gamemode: u8); + }; + const ctx_ = mcproto::context(ctx, "previous gamemode"); + const prev_gamemode = mcproto::decode_byte(&ctx_)?; + const ctx_ = mcproto::context(ctx, "is debug"); + const is_debug = mcproto::decode_bool(&ctx_)?; + const ctx_ = mcproto::context(ctx, "is flat"); + const is_flat = mcproto::decode_bool(&ctx_)?; + const ctx_ = mcproto::context(ctx, "copy metadata"); + const copy_metadata = mcproto::decode_bool(&ctx_)?; + const ctx_ = mcproto::context(ctx, "has death location"); + if (mcproto::decode_bool(&ctx_)?) { + const ctx_ = mcproto::context(ctx, "death dimension"); + const death_dim = mcproto::decode_string(&ctx_, 0x7fff)?; + const ctx_ = mcproto::context(ctx, "death position"); + const death_pos = decode_position(&ctx_)?; + }; + mcproto::expect_end(ctx)?; + + game_despawn(); + + GAMEMODE = gamemode; + trace::info(&trace::root, "gamemode {}", strgamemode(GAMEMODE)); + + let found = false; + for (let i = 0z; i < len(DIM_TYPES); i += 1) { + if (DIM_TYPES[i].name == dim_type) { + found = true; + DIM_TYPE = i; + }; + }; + if (!found) { + return mcproto::error(ctx, "Unknown dimension type {}", + dim_type); + }; + trace::info(&trace::root, "dimension type {}", + DIM_TYPES[DIM_TYPE].name); + + game_respawn(); +}; + +fn handle_keep_alive(ctx: *mcproto::Context) (void | trace::failed) = { + const ctx_ = mcproto::context(ctx, "id"); + const id = mcproto::decode_long(&ctx_)?; + mcproto::expect_end(ctx)?; + + let out: []u8 = []; + defer free(out); + mcproto::encode_long(&out, id); + network_send(0x11, out); +}; + +fn handle_set_center_chunk(ctx: *mcproto::Context) + (void | trace::failed) = { + const ctx_ = mcproto::context(ctx, "x"); + const x = mcproto::decode_varint(&ctx_)?; + const ctx_ = mcproto::context(ctx, "z"); + const z = mcproto::decode_varint(&ctx_)?; + mcproto::expect_end(ctx)?; + + setview(ChunkPos { x = x, z = z }); +}; + +type SyncPositionFlags = enum u8 { + X = 0x1, + Y = 0x2, + Z = 0x4, + // TODO: confirm that these are the correct way around; + // update wiki to clarify. + YAW = 0x8, + PITCH = 0xa, +}; + +fn handle_synchronize_player_position(ctx: *mcproto::Context) + (void | trace::failed) = { + const ctx_ = mcproto::context(ctx, "x"); + let x = mcproto::decode_double(&ctx_)?; + const ctx_ = mcproto::context(ctx, "y"); + let y = mcproto::decode_double(&ctx_)?; + const ctx_ = mcproto::context(ctx, "z"); + let z = mcproto::decode_double(&ctx_)?; + const ctx_ = mcproto::context(ctx, "yaw"); + let yaw = mcproto::decode_float(&ctx_)?; + const ctx_ = mcproto::context(ctx, "pitch"); + let pitch = mcproto::decode_float(&ctx_)?; + const ctx_ = mcproto::context(ctx, "flags"); + const flags = mcproto::decode_byte(&ctx_)?; + const ctx_ = mcproto::context(ctx, "teleport id"); + const id = mcproto::decode_varint(&ctx_)?; + const ctx_ = mcproto::context(ctx, "dismount"); + const dismount = mcproto::decode_bool(&ctx_)?; + mcproto::expect_end(ctx)?; + + if (flags & SyncPositionFlags::X != 0) x += PLAYER_POS[0]; + if (flags & SyncPositionFlags::Y != 0) y += PLAYER_POS[1]; + if (flags & SyncPositionFlags::Z != 0) z += PLAYER_POS[2]; + if (flags & SyncPositionFlags::YAW != 0) yaw += PLAYER_YAW; + if (flags & SyncPositionFlags::PITCH != 0) pitch += PLAYER_PITCH; + + PLAYER_POS = [x: f32, y: f32, z: f32]; + PLAYER_YAW = yaw; + PLAYER_PITCH = pitch; + + let out: []u8 = []; + defer free(out); + mcproto::encode_varint(&out, id); + network_send(0x00, out); + + // TODO: is this necessary? (probably for anticheat reasons, at + // least...) + let out: []u8 = []; + defer free(out); + mcproto::encode_double(&out, PLAYER_POS[0]); + mcproto::encode_double(&out, PLAYER_POS[1]); + mcproto::encode_double(&out, PLAYER_POS[2]); + mcproto::encode_float(&out, PLAYER_YAW); + mcproto::encode_float(&out, PLAYER_PITCH); + mcproto::encode_bool(&out, true); + network_send(0x14, out); +}; + +fn handle_set_health(ctx: *mcproto::Context) (void | trace::failed) = { + const ctx_ = mcproto::context(ctx, "health"); + const health = mcproto::decode_float(&ctx_)?; + const ctx_ = mcproto::context(ctx, "food"); + const food = mcproto::decode_varint(&ctx_)?; + const ctx_ = mcproto::context(ctx, "saturation"); + const saturation = mcproto::decode_float(&ctx_)?; + mcproto::expect_end(ctx)?; + + PLAYER_HEALTH = health; + PLAYER_FOOD = food; + PLAYER_SATURATION = saturation; +}; + +fn handle_combat_death(ctx: *mcproto::Context) (void | trace::failed) = { + const ctx_ = mcproto::context(ctx, "player id"); + const player_id = mcproto::decode_varint(&ctx_)?; + const ctx_ = mcproto::context(ctx, "entity id"); + const entity_id = mcproto::decode_int(&ctx_)?; + const ctx_ = mcproto::context(ctx, "message"); + const message = mcproto::decode_string(&ctx_, 0x7fff)?; + mcproto::expect_end(ctx)?; + + trace::info(&trace::root, "death: {}", message); + + death_show(message); +}; + +fn handle_set_default_spawn_position(ctx: *mcproto::Context) + (void | trace::failed) = { + const ctx_ = mcproto::context(ctx, "position"); + const pos = decode_position(&ctx_)?; + const ctx_ = mcproto::context(ctx, "yaw"); + const yaw = mcproto::decode_float(&ctx_)?; + + LOADING_RECEIVED_SPAWN_POSITION = true; +}; + +fn handle_block_update(ctx: *mcproto::Context) (void | trace::failed) = { + const ctx_ = mcproto::context(ctx, "position"); + const pos = decode_position(&ctx_)?; + const ctx_ = mcproto::context(ctx, "blockstate"); + const bstate = mcproto::decode_varint(&ctx_)?; + if (bstate & ~0xffff != 0) { + return mcproto::error(&ctx_, "Blockstate id too large"); + }; + const bstate = bstate: u16; + mcproto::expect_end(&ctx_)?; + + if (getsection(block_section(pos)) is null) { + mcproto::log(ctx, trace::level::WARN, + "Block update references nonexistent chunk: ( {} {} {} )", + pos.x, pos.y, pos.z); + return; + }; + + setblock(pos, bstate); +}; + +fn handle_update_section_blocks(ctx: *mcproto::Context) + (void | trace::failed) = { + const ctx_ = mcproto::context(ctx, "section position"); + const section_pos = mcproto::decode_long(&ctx_)?: i64; + const section_pos = SectionPos { + x = (section_pos >> 42): i32, + y = (section_pos << 44 >> 44): i8, + z = (section_pos << 22 >> 42): i32, + }; + if (getsection(section_pos) is null) { + mcproto::log(ctx, trace::level::WARN, + "Section update references nonexistent chunk: [ {} {} {} ]", + section_pos.x, section_pos.y, section_pos.z); + return; + }; + const ctx_ = mcproto::context(ctx, "suppress light updates"); + const suppress_light_updates = mcproto::decode_bool(&ctx_)?; + const ctx_ = mcproto::context(ctx, "block count"); + const nblocks = mcproto::decode_varint(&ctx_)?; + for (let i = 0i32; i < nblocks; i += 1) { + const ctx_ = mcproto::context(ctx, "entry {}", i); + // TODO: should be varlong + const entry = mcproto::decode_varint(&ctx_)?; + const pos = BlockPos { + x = (section_pos.x << 4) + (entry >> 8 & 0xf), + y = (section_pos.y: i16 << 4) + (entry & 0xf): i16, + z = (section_pos.z << 4) + (entry >> 4 & 0xf), + }; + const bstate = entry >> 12; + if (bstate & ~0xffff != 0) { + return mcproto::error(&ctx_, "Blockstate id too large"); + }; + const bstate = bstate: u16; + + setblock(pos, bstate); + }; + mcproto::expect_end(ctx)?; +}; + +fn handle_chunk_data_and_update_light(ctx: *mcproto::Context) + (void | trace::failed) = { + const ctx_ = mcproto::context(ctx, "x"); + const x = mcproto::decode_int(&ctx_)?: i32; + const ctx_ = mcproto::context(ctx, "z"); + const z = mcproto::decode_int(&ctx_)?: i32; + const chunk_pos = ChunkPos { x = x, z = z }; + if (getchunk(chunk_pos) is null) { + return mcproto::error(ctx, + "Chunk out of range: [ {} {} ]", + chunk_pos.x, chunk_pos.z); + }; + const ctx_ = mcproto::context(ctx, "heightmaps"); + const heightmaps = decode_nbt(&ctx_)?; + free(heightmaps.0); + const heightmaps = heightmaps.1; + defer nbt::finish(heightmaps); + const ctx_ = mcproto::context(ctx, "data length"); + const datalen = mcproto::decode_varint(&ctx_)?; + if (datalen < 0) { + return mcproto::error(&ctx_, "Data length must not be negative"); + }; + const datalen = datalen: u32; + const ctx_ = mcproto::context(ctx, "chunk data"); + mcproto::decode_nbytes(&ctx_, datalen)?; + let cdec = *ctx.dec; + cdec.input = cdec.input[..cdec.pos]; + cdec.pos -= datalen; + const cctx = mcproto::root(&cdec); + + const chunk = chunk_init(chunk_pos); + + for (let i = 0z; i < CHUNKS_HEIGHT; i += 1) { + const cctx_ = mcproto::context(&cctx, "block count"); + const block_count = mcproto::decode_short(&cctx_)?; + const cctx_ = mcproto::context(&cctx, "bits per entry"); + const bpe = mcproto::decode_byte(&cctx_)?; + + const bstates = if (bpe == 0) { // constant + const cctx_ = mcproto::context(&cctx, "constant value"); + const value = mcproto::decode_varint(&cctx_)?; + if (value > 0xffff) { + return mcproto::error(&cctx_, "Blockstate id too large"); + }; + const cctx_ = mcproto::context(&cctx, "array length"); + const arraylen = mcproto::decode_varint(&cctx_)?; + if (arraylen != 0) { + return mcproto::error(&cctx_, + "Chunk data array must be empty when bpe = 0"); + }; + let array = comparray::new(1, 4096); + comparray::clear(&array, value: u16); + yield array; + } else { + let array = if (bpe >= 9) { // direct + yield comparray::new(0, 4096); + } else { // indirect + const cctx_ = mcproto::context(&cctx, + "palette length"); + const palettelen = + mcproto::decode_varint(&cctx_)?: u32; + if (palettelen >= 256 || palettelen < 2) { + // = 256 is actually possible, but it's + // going to be a huge pain. oh well. + return mcproto::error(&cctx_, "Uh oh."); + }; + let array = + comparray::new(palettelen: u8, 4096); + let palette = comparray::get_palette(&array); + for (let i = 0z; i < palettelen; i += 1) { + const cctx_ = mcproto::context(&cctx, + "palette entry {}", i); + const value = + mcproto::decode_varint(&cctx_)?; + if (value > 0xffff) { + return mcproto::error(&cctx_, + "Blockstate id too large"); + }; + palette[i] = value: u16; + }; + yield array; + }; + + const bpe = if (bpe <= 4) 4u8 + else if (bpe <= 8) bpe: u8 + else 15u8; + + const cctx_ = mcproto::context(&cctx, "array length"); + const arraylen = mcproto::decode_varint(&cctx_)?; + const epl = 64 / bpe; + const arraylen_ = (4096z + epl - 1) / epl; + if (arraylen: size != arraylen_) { + return mcproto::error(&cctx_, + "Chunk data array with bpe = {} must have length {}, not {}", + bpe, arraylen_, arraylen); + }; + const cctx_ = mcproto::context(&cctx, "array data"); + let arraydata = + mcproto::decode_nbytes(&cctx_, + arraylen: size * 8)?; + + let pos = 0z; + let long = 0u64; + let shift = 64u8; + const mask = (1u64 << bpe) - 1; + for (let i = 0z; i < 4096; i += 1) { + if (shift + bpe > 64) { + long = endian::begetu64( + arraydata[pos..pos + 8]); + pos += 8; + shift = 0; + }; + const value = long >> shift & mask; + shift += bpe; + if (array.palette_size != 0 + && value > array.palette_size) { + return mcproto::error(&cctx_, + "Palette index out of range (entry {})", + i); + }; + if (array.palette_size == 0 && value > 0xffff) { + return mcproto::error(&cctx_, + "Blockstate id too large (entry {})", + i); + }; + comparray::set(&array, i, value: u16); + }; + + yield array; + }; + + const cctx_ = mcproto::context(&cctx, "bits per entry"); + const bpe = mcproto::decode_byte(&cctx_)?; + if (bpe == 0) { // constant + const cctx_ = mcproto::context(&cctx, "constant value"); + mcproto::decode_varint(&cctx_)?; + } else if (bpe < 4) { // indirect + const cctx_ = mcproto::context(&cctx, "palette length"); + const palettelen = mcproto::decode_varint(&cctx)?: u32; + for (let i = 0z; i < palettelen; i += 1) { + const cctx_ = mcproto::context(&cctx, + "palette entry {}", i); + mcproto::decode_varint(&cctx_)?; + }; + }; + const cctx_ = mcproto::context(&cctx, "array length"); + const arraylen = mcproto::decode_varint(&cctx_)?: u32; + const cctx_ = mcproto::context(&cctx, "array data"); + mcproto::decode_nbytes(&cctx_, arraylen: size * 8)?; + + const section = &(chunk.sections as *[*]Section)[i]; + section.bstates = bstates; + section.dirty = true; + }; + + // other stuff we don't care about for now. +}; + +fn handle_unload_chunk(ctx: *mcproto::Context) + (void | trace::failed) = { + const ctx_ = mcproto::context(ctx, "x"); + const x = mcproto::decode_int(&ctx_)?: i32; + const ctx_ = mcproto::context(ctx, "z"); + const z = mcproto::decode_int(&ctx_)?: i32; + chunk_destroy(ChunkPos { x = x, z = z }); +}; + +// this is really awkward, but i hear a "match overhaul" is coming that should +// improve things... +fn nbt_get(obj: *nbt::Object, key: str) + (...nbt::Value | void) = { + match (nbt::get(obj, key)) { + case let value: *nbt::Value => + return *value; + case null => + return void; + }; +}; -- cgit v1.2.3