Files
minecraft_protocol/packets/format-chunk-data.md
T
claude-timemachine 90b711d12a packets/: packet model + catalogs + wire-format deep-dives
Control-state catalogs (handshake/status/login/config), categorized Play
catalog (~182 packets), and deep-dives on the four hard formats: chunk data
(paletted containers + light), entity metadata (type registry), slot/structured
components, command graph (Brigadier). Sourced from minecraft-data + ViaVersion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 15:15:26 +02:00

28 KiB
Raw Blame History

Packet deep-dive — Chunk Data wire format & its evolution

The single hardest wire format in the Java protocol. This doc nails the current (1.18+) "Chunk Data and Update Light" packet byte-for-byte — including the paletted container, the format's crux — then walks the evolution back through 1.17, 1.14, 1.9, and 1.8.

Cross-links: 1.8 · 1.14 · 1.17 · 1.18 · data-type primitives in 01-data-types.md (NBT §11, BitSet §16, VarInt, Long Array).

Primary sources (cited inline as path:line):

  • minecraft-data data/pc/{1.8,1.14,1.17,1.18,1.21.1}/protocol.jsonpacket_map_chunk / packet_update_light defs, diffed for evolution.
  • ViaVersion (the authoritative paletted-container + section impl): api/.../type/types/chunk/ChunkType1_18.java, ChunkSectionType1_18.java, PaletteType1_18.java, ChunkType1_8.java; api/.../minecraft/chunks/PaletteType.java, ChunkSection.java; util/CompactArrayUtil.java; api/.../type/types/block/BlockEntityType1_18.java; common/.../protocols/v1_17_1to1_18/rewriter/WorldPacketRewriter1_18.java, .../storage/ChunkLightStorage.java.
  • minecraft.wiki Java Edition protocol / Chunk format and Packets (fetched 2026-06-19).
Version Protocol # minecraft-data dir Chunk packet name
1.8.x 47 1.8 packet_map_chunk (0x21) — light embedded
1.14.x 477 1.14 packet_map_chunk (0x22) + separate packet_update_light
1.17.x 755 1.17 packet_map_chunk + packet_update_light (masks now Long-array BitSets)
1.18.x 757 1.18 LEVEL_CHUNK_WITH_LIGHT (0x22) — light re-merged
1.21.1 767 1.21.1 level_chunk_with_light (0x27) — trustEdges dropped

Protocol numbers from data/pc/<ver>/version.json.


1. Current packet — "Chunk Data and Update Light" (1.18+)

Clientbound, Play state. Named LEVEL_CHUNK_WITH_LIGHT in ViaVersion / packet_map_chunk in minecraft-data. Packet ID is version-dependent (1.18: 0x22; 1.21.1: 0x27data/pc/1.21.1/protocol.json play.toClient.types.packet).

Three logical blocks in one packet: (A) Chunk Data, (B) Block Entities, (C) Light Data. The 1.18 merge is exactly: take the old LEVEL_CHUNK (A + B) and append the old LIGHT_UPDATE body (C) with no separator.

1.0 Top-level layout

LEVEL_CHUNK_WITH_LIGHT (clientbound, Play)
┌──────────────────────────────────────────────────────────────────────────┐
│ Int            chunkX                                                       │  ── A
│ Int            chunkZ                                                       │
│ NBT            heightmaps      (named compound; 1.21.1+ anonymous compound) │
│ VarInt         dataSize        (byte length of the Data field below)        │
│ Byte[dataSize] data            = ySectionCount × Chunk Section (see §1.2)    │
│ VarInt         blockEntityCount                                            │  ── B
│ BlockEntity[]  blockEntities   (count above; struct in §1.3)               │
│ ── light (was the separate LIGHT_UPDATE packet pre-1.18) ──                 │  ── C
│ Boolean        trustEdges      (PRESENT 1.181.20; REMOVED 1.21.1+)         │
│ BitSet         skyLightMask                                                 │
│ BitSet         blockLightMask                                              │
│ BitSet         emptySkyLightMask                                          │
│ BitSet         emptyBlockLightMask                                        │
│ VarInt         skyLightArrayCount                                          │
│ ByteArray[]    skyLightArrays  (each: VarInt len(=2048) + 2048 bytes)       │
│ VarInt         blockLightArrayCount                                       │
│ ByteArray[]    blockLightArrays(each: VarInt len(=2048) + 2048 bytes)       │
└──────────────────────────────────────────────────────────────────────────┘

Field order/types confirmed: ChunkType1_18.java:48-86 (read/write of chunkX, chunkZ, heightMap, dataSize, sections, blockEntityCount, blockEntities) and WorldPacketRewriter1_18.java:166-196 (the appended light block). minecraft-data packet_map_chunk for 1.18 lists exactly: x i32, z i32, heightmaps nbt, chunkData buffer(varint), blockEntities array(chunkBlockEntity), trustEdges bool, skyLightMask i64[], blockLightMask i64[], emptySkyLightMask i64[], emptyBlockLightMask i64[], skyLight array(array(u8)), blockLight array(array(u8)).

trustEdges lifetime. 1.18 keeps the trustEdges boolean from the old LIGHT_UPDATE (WorldPacketRewriter1_18.java:81,183; minecraft-data 1.18 packet_map_chunk). It was removed in 1.21.x — minecraft-data 1.21.1/packet_map_chunk has no trustEdges field. Use the per-version packet_map_chunk to be exact.

1.1 ySectionCount — how many sections in data

data is a flat concatenation of Chunk Sections, one per 16-block-tall Y layer, with no count prefix of its own — the receiver must already know how many to read. The count is the dimension's section count, derived from the dimension-type NBT (height >> 4):

Dimension World Y Block height Sections (height>>4)
Overworld (1.18+) 64 … 319 384 24
Pre-1.18 overworld 0 … 255 256 16
Nether / End 0 … 255 256 16

ViaVersion reads height from the registry NBT in LOGIN/RESPAWN and stores height >> 4 as currentWorldSectionHeight(), then passes it to new ChunkType1_18(ySectionCount, …) so decoding is dimension-aware, not hardcoded to 24 (ChunkType1_18.java:41-46,55-59; tracker plumbing in versions/1.18.md §"World height in the dimension type NBT"). Every section is always serialised, even empty ones (single-value air palette) — there is no longer a presence bitmask (see §3.4).

Light arrays index a larger range than ySectionCount: there are ySectionCount + 2 light layers (one extra below the world and one above, for edge lighting). ViaVersion's empty-light fallback sets the mask over currentWorldSectionHeight() + 2 bits (WorldPacketRewriter1_18.java:173-174). For the overworld that is 26 light layers around 24 block sections.

1.2 Chunk Section (the unit inside data)

Chunk Section
┌────────────────────────────────────────────────────────────┐
│ Short            blockCount   (non-air block count)          │
│ PalettedContainer blockStates (4096 entries = 16×16×16)      │
│ PalettedContainer biomes      (64 entries = 4×4×4)  ◄ NEW 1.18│
└────────────────────────────────────────────────────────────┘

Source: ChunkSectionType1_18.java:49-63 — reads Short non-air count, then the BLOCKS paletted container, then the BIOMES paletted container. Sizes: ChunkSection.java:32 SIZE = 16*16*16 = 4096; ChunkSection.java:37 BIOME_SIZE = 4*4*4 = 64.

  • blockCount — number of non-air blocks; the client uses it to skip rendering / mark a section empty. Air-only sections have blockCount = 0.
  • blockStates — paletted container over 4096 entries (one per block in the 16³ section). Entry = a block-state global-registry ID, not a block ID. Cell index ordering is YZX: index = (y<<8) | (z<<4) | x (ChunkSection.java:39).
  • biomes — paletted container over 64 entries (4×4×4 sub-grid; one biome per 4³ cell). Entry = a biome registry ID. This container did not exist before 1.18 — biomes lived in a flat chunk-level array (§3).

1.3 Block Entity (the array after data)

VarInt blockEntityCount
  repeated:
  ┌──────────────────────────────────────────────────┐
  │ Byte   packedXZ  = ((x & 15) << 4) | (z & 15)      │
  │ Short  y          (absolute world Y, signed i16)   │
  │ VarInt type       (block-entity registry type ID)  │
  │ NBT    data       (optional NBT; may be TAG_End)   │
  └──────────────────────────────────────────────────┘

Source: BlockEntityType1_18.java:38-53 (byte xz, short y, varint typeId, named-compound NBT) and ChunkType1_18.java:61-65. minecraft-data top-level type chunkBlockEntity for 1.18 decomposes packedXZ as a bitfield {x: u4 (high nibble), z: u4 (low nibble)}, then y i16, type varint, nbtData optionalNbt. x/z are section-local (015); the section is identified by the packet's chunkX/chunkZ. ViaVersion packs it as (byte)((x & 15) << 4 | (z & 15)) when up-converting (WorldPacketRewriter1_18.java:127).

This packed form is new in 1.18 — pre-1.18 block entities were raw NBT compounds carrying their own x/y/z/id fields (§3.3, §4).

1.4 Light Data (block C)

Re-merged into this packet in 1.18 (was the standalone LIGHT_UPDATE since 1.14). Layout (WorldPacketRewriter1_18.java:182-196, write side):

Boolean   trustEdges            (1.181.20 only; gone 1.21.1+)
BitSet    skyLightMask          \
BitSet    blockLightMask         |  each = VarInt length + that many i64 (longs)
BitSet    emptySkyLightMask      |  → see 01-data-types.md §16 (prefixed BitSet)
BitSet    emptyBlockLightMask   /
VarInt    skyLightArrayCount
  repeated skyLightArrayCount×:  ByteArray  (VarInt length = 2048, then 2048 bytes)
VarInt    blockLightArrayCount
  repeated blockLightArrayCount×: ByteArray  (VarInt length = 2048, then 2048 bytes)
  • Each BitSet is wire-encoded as a Long Array: a VarInt element-count followed by that many i64 (little-endian-within-long bit indexing per the BitSet spec). ViaVersion writes them with Types.LONG_ARRAY_PRIMITIVE (WorldPacketRewriter1_18.java:184-187); minecraft-data types them array(countType: varint, type: i64). The mask bit positions correspond to the ySectionCount + 2 light layers (bit 0 = the layer below the world).
  • Sky/Block Light masks mark which light layers are present in the following arrays; Empty masks mark layers that are explicitly all-zero (so no array is sent for them). A layer's bit set in the mask ⇒ one 2048-byte array follows, in mask-bit order.
  • Each light array is exactly 2048 bytes = 4096 nibbles = one 4-bit light level per block in a 16³ layer. The wire form is still a length-prefixed byte array (VarInt 2048 then the bytes — Types.BYTE_ARRAY_PRIMITIVE, WorldPacketRewriter1_18.java:189-194); minecraft-data types it array(varint, array(varint, u8)).

2. PALETTED CONTAINER — the crux (1.18 impl, format introduced 1.9)

A paletted container compresses a fixed number of entries (4096 for blocks, 64 for biomes) by choosing, per container, the smallest practical bits-per-entry and an optional local palette. Authoritative impl: PaletteType1_18.java:43-129 (read/write); thresholds in PaletteType.java:25-27.

PalettedContainer
┌──────────────────────────────────────────────────────────────────────┐
│ Unsigned Byte  bitsPerEntry                                            │
│ <palette>      ← format SELECTED BY bitsPerEntry (see below)           │
│ <data>         ← Long Array: VarInt length + that many i64             │
└──────────────────────────────────────────────────────────────────────┘

bitsPerEntry is read as a single byte (PaletteType1_18.java:45). The three formats:

2.1 Single-valued palette — bitsPerEntry == 0

The entire container is one value (e.g. a whole section of air, or a whole biome).

Byte    0
VarInt  value          ← the one global-registry ID for every entry
VarInt  dataArrayLen = 0   (empty data array — NO long data)

Read: PaletteType1_18.java:47-53 — on bitsPerEntry == 0, read one VarInt id, then readValues returns immediately because bitsPerValue == 0. Write: lines 92-100 + 116-121 — write byte 0, the single id, then VarInt 0 for the (empty) data-array length. Note the empty long array still has its VarInt length prefix (= 0).

2.2 Indirect palette

A local palette lists the global IDs actually used; the data array stores indices into that local palette.

Byte    bitsPerEntry        (blocks: 48 ; biomes: 13)
VarInt  paletteLength
VarInt[paletteLength] palette    ← global IDs; data entries index THIS array
VarInt  dataArrayLen
i64[dataArrayLen]   data          ← packed paletteLength-indices, bitsPerEntry wide

Read: PaletteType1_18.java:62-73 — when bitsPerValue != globalPaletteBits, read paletteLength then that many VarInt ids into the local palette, then the packed values (interpreted via setPaletteIndexAt). Write: lines 105-113.

Bits-per-entry ranges (the load-bearing numbers):

Container Single Indirect Direct (global)
Block states bpe 0 bpe 48 bpe ≥ 9 → global
Biomes bpe 0 bpe 13 bpe ≥ 4 → global
  • The indirect ceiling is PaletteType.highestBitsPerValue(): 8 for BLOCKS, 3 for BIOMES (PaletteType.java:26-27BLOCKS(ChunkSection.SIZE, 8), BIOMES(ChunkSection.BIOME_SIZE, 3)). Above the ceiling the reader switches to the global/direct palette: if (bitsPerValue < 0 || bitsPerValue > type.highestBitsPerValue()) bitsPerValue = globalPaletteBits (PaletteType1_18.java:55-56).
  • Block-palette floor = 4 bits. Linear (indirect) block palettes of 1/2/3 bits "can't be read by the client", so the writer clamps to 4 (PaletteType1_18.java:57-59,133 and the comment at 132) and the reader bumps any 1 ≤ bpe < 4 up to 4 (:57-59). Biomes have no floor — they legitimately use 1, 2, 3.
  • The wiki gives the same ranges from the client's view: blocks "Indirect (BPE 4-8)", "Direct (BPE ≥15)"; biomes "Indirect (1-3)", "Direct (≥7)" (Chunk format, fetched 2026-06-19). The wiki's "≥15"/"≥7" are the actual global-palette bit widths vanilla emits (≈ceil(log2(registry_size))), whereas ViaVersion's threshold is "anything above the indirect ceiling (8 / 3) ⇒ use globalPaletteBits". Both describe the same switch: indirect tops out at 8 (blocks) / 3 (biomes); everything wider is direct.

2.3 Direct palette — bitsPerEntry == globalPaletteBits

No local palette. Data entries are global-registry IDs packed directly.

Byte    bitsPerEntry = globalPaletteBits   (blocks ~15, biomes ~6/7)
VarInt  dataArrayLen
i64[dataArrayLen]   data    ← packed global IDs, bitsPerEntry wide

Read: PaletteType1_18.java:68-70 — when bitsPerValue == globalPaletteBits, skip the palette and read values straight via setIdAt. globalPaletteBits is passed into the type at construction. ViaVersion computes it as ceilLog2(blockStateMappings.mappedSize()) for blocks and ceilLog2(tracker.biomesSent()) for biomes (WorldPacketRewriter1_18.java:159-161) — i.e. enough bits to index the whole registry.

2.4 Data-array bit packing — no entry spans two longs

This is the detail people get wrong. In the modern (1.16+) format entries are packed so that each entry stays inside one 64-bit long; leftover high bits at the top of each long are padding (zero), not the start of the next entry.

valuesPerLong = floor(64 / bitsPerEntry)          ← truncating division
longCount     = ceil(entries / valuesPerLong)      ← entries = 4096 (blocks) or 64 (biomes)
within a long: entry0 in the LOW bits, then entry1 above it, …; top (64 mod bpe) bits = padding

Source: CompactArrayUtil.createCompactArrayWithPadding:55-73 (write) and iterateCompactArrayWithPadding:75-89 (read) — valuesPerLong = (char)(64 / bitsPerEntry), size = (entries + valuesPerLong - 1) / valuesPerLong, each value shifted by bitIndex += bitsPerEntry and the loop bounded by min(i + valuesPerLong, entries) so it never crosses into the next long. PaletteType1_18.readValues:77-89 computes the expected long count the same way and only iterates if values.length == expectedLength. The wiki states it identically: entries "cannot span across multiple longs; instead, padding is inserted… starting from the most significant bits", "tightly packed within the long, with the first entry on the least significant bits".

Contrast — the OLD tightly-packed format (pre-1.16). CompactArrayUtil.createCompactArray:91-107 / iterateCompactArray:109-125 pack entries with no padding, so an entry can straddle a long boundary (startIndex != endIndex branch at :101-104 / :119-122). 1.91.15 used this; 1.16 switched to the padded form above. A decoder must pick the packing by version — same palette framing, different long math.

Worked sizes (modern padded format):

  • Blocks, bpe = 4: valuesPerLong = 16, longCount = ceil(4096/16) = 256 longs.
  • Blocks, bpe = 8: valuesPerLong = 8, longCount = ceil(4096/8) = 512 longs.
  • Blocks, bpe = 15 (direct): valuesPerLong = 4, longCount = ceil(4096/4) = 1024 longs.
  • Biomes, bpe = 1: valuesPerLong = 64, longCount = ceil(64/64) = 1 long.
  • Biomes, bpe = 3: valuesPerLong = 21, longCount = ceil(64/21) = 4 longs.

3. Evolution — what changed at each step

Diff source: data/pc/<ver>/protocol.json packet_map_chunk (+ packet_update_light). The five steps below are the structural inflection points.

3.1 — 1.8 (proto 47): per-section palette is born; presence bitmask; light embedded

packet_map_chunk (1.8) = { x i32, z i32, groundUp bool, bitMap u16, chunkData buffer(varint) }. Inside chunkData (decoded by ChunkType1_8.deserialize:88-124):

for each of 16 sections, IF (bitMap >> i) & 1:   block data (per-section palette)
for each present section:                        block light  (2048 bytes)
if NORMAL dimension, for each present section:    sky light    (2048 bytes)
if groundUp (full chunk):                         biome array  = 256 bytes (one per X,Z column)

Key facts of this era:

  • Per-section block palette introduced (the ancestor of today's paletted container) — sections carry their own palette + packed data.
  • bitMap (u16) selects which of the 16 sections are present (ChunkType1_8.read:58, decode loop :95-98). groundUp/full-chunk (:57) means "replace the whole column, biomes included".
  • Light is INSIDE the chunk packet, interleaved per section, block-light then sky-light (deserialize:100-112). There is no separate light packet yet.
  • Biomes = a flat 256-byte array (one biome byte per horizontal column, 2-D), only on full chunks (:115-120). No per-section, no 3-D biomes.
  • No heightmaps; no block-entity array (block entities came via separate update_block_entity packets / chunk-bulk).

Source: ChunkType1_8.java:53-160; minecraft-data 1.8/packet_map_chunk (groundUp bool, bitMap u16). Cross-link: versions/1.8.md.

3.2 — 1.9 (proto 107+): block-entity NBT array appended

1.9.3+ appends a block-entities array to the chunk: count + that many full NBT compounds, each self-describing via its own x/y/z/id tags. This is why pre-1.18 the block entity is "just NBT" (contrast §1.3's packed struct). The 1.9 line also moved block IDs to the global block-state registry that the palette indexes.

(minecraft-data carries the array on packet_map_chunk from 1.14 onward in the dirs we diffed; the 1.9-era addition is documented in versions/1.9.md. The flat-packed long arithmetic of this era is the createCompactArray/iterateCompactArray "entries CAN span longs" form — see §2.4.)

3.3 — 1.14 (proto 477): light SPLIT OUT; heightmaps NBT added

packet_map_chunk (1.14) = { x i32, z i32, groundUp bool, bitMap varint, heightmaps nbt, chunkData buffer(varint), blockEntities array(nbt) }. New packet_update_light = { chunkX varint, chunkZ varint, skyLightMask varint, blockLightMask varint, emptySkyLightMask varint, emptyBlockLightMask varint, data restBuffer }.

Changes vs 1.8/1.9:

  • Light removed from the chunk packet and moved to a separate Update Light packet (client now receives chunk and light as two packets). The chunk packet no longer carries the per-section light arrays.
  • heightmaps NBT added to the chunk (MOTION_BLOCKING, WORLD_SURFACE, etc. — packed long arrays inside an NBT compound), supporting the new lighting engine.
  • bitMap widened u16 → VarInt (still a section-presence bitmask).
  • Block entities are an explicit array(nbt) field on the packet (full NBT each).
  • In 1.14 the update_light masks are still single VarInts (≤32 sections fit in 32 bits).

Source: minecraft-data 1.14/packet_map_chunk + 1.14/packet_update_light. Cross-link: versions/1.14.md.

3.4 — 1.17 (proto 755): full-chunks-only; masks become Long-array BitSets; biomes go 3-D-flat

packet_map_chunk (1.17) = { x i32, z i32, bitMap i64[], heightmaps nbt, biomes array(varint), chunkData buffer(varint), blockEntities array(nbt) }. packet_update_light (1.17) = { chunkX, chunkZ, trustEdges bool, skyLightMask i64[], blockLightMask i64[], emptySkyLightMask i64[], emptyBlockLightMask i64[], skyLight array(array(u8)), blockLight array(array(u8)) }.

Changes vs 1.14:

  • groundUp / partial-chunk dropped — 1.17 only sends full chunks; there is no longer a "groundUp" boolean (the field is gone from packet_map_chunk). The world-height growth to come needed a clean full-column model.
  • bitMap became a Long-array BitSet (array(varint, i64)) instead of a VarInt, because section counts could exceed 32. The light masks in update_light likewise became Long-array BitSets, and trustEdges + explicit skyLight/blockLight array-of-arrays appeared (the form §1.4 later inherits).
  • Biomes promoted to a chunk-level VarInt[] = 1024 entries (4×4×4 per section × 16 sections) — full 3-D biomes, but still a flat array at chunk level, not per-section. ViaVersion reads this flat array and re-slices it into per-section palettes when up-converting to 1.18 (WorldPacketRewriter1_18.java:131,146-155).
  • World height still 0…255 (16 sections).

Source: minecraft-data 1.17/packet_map_chunk + 1.17/packet_update_light; ChunkType1_17 (read) referenced from WorldPacketRewriter1_18.java:107. Cross-link: versions/1.17.md.

3.5 — 1.18 (proto 757): height 64…319 (24 sections); biomes → per-section container; light RE-MERGED

packet_map_chunk (1.18) drops bitMap and the chunk-level biomes array, and appends the whole update_light body: { x i32, z i32, heightmaps nbt, chunkData buffer(varint), blockEntities array(chunkBlockEntity), trustEdges bool, skyLightMask i64[], blockLightMask i64[], emptySkyLightMask i64[], emptyBlockLightMask i64[], skyLight array(array(u8)), blockLight array(array(u8)) }.

Changes vs 1.17 (the heavy ones):

  • World height 64…319 ⇒ 24 sections (was 16). Section count is dimension-driven (height >> 4), not constant (§1.1).
  • Section presence bitmask REMOVEDevery section is serialised, empty ones using the single-value air palette. ViaVersion synthesises an air section for previously-absent ones when up-converting: new ChunkSectionImpl() + a single-id palette of 0 (WorldPacketRewriter1_18.java:135-144).
  • Biomes moved INTO each section as a paletted container (4×4×4, §1.2/§2). The flat chunk-level biomes VarInt[] is gone; ViaVersion fills the per-section biome palettes from the old flat array (WorldPacketRewriter1_18.java:146-155).
  • Light RE-MERGED into "Chunk Data and Update Light" — the separate LIGHT_UPDATE body is appended directly (§1.4). Proxies bridging 1.17↔1.18 must buffer one packet until the other arrives (ChunkLightStorage.java; WorldPacketRewriter1_18.java:66-103,163-196).
  • Block entities packed into chunkBlockEntity structs (packedXZ/y/type/optNBT) instead of full self-describing NBT (§1.3).

Source: minecraft-data 1.17 vs 1.18 packet_map_chunk; ViaVersion ChunkType1_18.java, ChunkSectionType1_18.java, WorldPacketRewriter1_18.java. Full prose in versions/1.18.md §"Chunk packet restructure".

3.6 — after 1.18 (context)

  • 1.21.x: trustEdges removed from the chunk packet (minecraft-data 1.21.1/packet_map_chunk has no trustEdges); packet ID shifted to 0x27. heightmaps switched to anonymous (network/unnamed) NBT (anonymousNbt).
  • 1.16: the data-array packing changed from "entries-can-span-longs" to the padded "no entry spans two longs" form (§2.4) — a silent but decoder-breaking change with no field-list delta.
  • 26.1+: a second Short fluidCount is added to each Chunk Section (ViaVersion ChunkSection.getFluidCount "Available with versions 26.1+", ChunkSection.java:67-73).

4. Quick comparison matrix

Aspect 1.8 (47) 1.14 (477) 1.17 (755) 1.18+ (757)
Sections in data 16, bitmask-selected 16, bitmask-selected 16, full-chunk only 24 (dim-driven), all present
Section presence bitMap u16 bitMap VarInt bitMap i64[] BitSet no mask (air palette)
Block palette per-section (spanning longs) per-section per-section per-section, padded longs (§2.4)
Biomes 256-byte 2-D array 256-byte (full chunk) flat VarInt[1024] 3-D per-section paletted container
Heightmaps none NBT added NBT NBT (anon NBT in 1.21+)
Block entities none (separate pkts) NBT array NBT array packed chunkBlockEntity
Light embedded in chunk separate Update Light separate Update Light re-merged into chunk pkt
Light masks n/a (interleaved) single VarInt i64[] BitSet i64[] BitSet

5. Paletted-container cheat-sheet (decoder pseudocode, 1.18+)

read_paletted_container(buf, entries, indirectCeiling, globalBits):   # blocks: 4096,8,~15  biomes: 64,3,~6
    bpe = buf.read_u8()
    if bpe == 0:                                   # single-valued
        value = buf.read_varint()
        _len  = buf.read_varint()                  # == 0, empty data array
        return uniform(entries, value)
    if bpe > indirectCeiling:                       # direct / global
        bpe = globalBits
        palette = None                              # data holds global IDs
    else:                                           # indirect
        if blocks and bpe < 4: bpe = 4              # block-palette floor (biomes: no floor)
        n = buf.read_varint(); palette = [buf.read_varint() for _ in range(n)]
    longs = buf.read_varint(); data = [buf.read_i64() for _ in range(longs)]
    # unpack: valuesPerLong = 64 // bpe ; entry i is in long (i // valuesPerLong),
    #         at bit ((i % valuesPerLong) * bpe), masked to bpe bits.  NO entry spans two longs.
    return [ palette[idx] if palette else idx  for idx in unpack(data, bpe, entries) ]

Matches PaletteType1_18.read:43-89 + CompactArrayUtil.iterateCompactArrayWithPadding:75-89 exactly.