๐ Source: snbt.php
<?php
/**
* Minimal SNBT (Stringified NBT) parser, tuned for modern Minecraft
* (1.20.5+ component format) as well as the older tag format.
*/
class SnbtParseError extends Exception {}
class SnbtParser {
private string $s;
private int $i = 0;
private int $len;
public function __construct(string $input) {
$this->s = $input;
$this->len = strlen($input);
}
public static function parse(string $input) {
$p = new self($input);
$p->skipWs();
$v = $p->parseValue();
$p->skipWs();
return $v;
}
private function skipWs(): void {
while ($this->i < $this->len && ctype_space($this->s[$this->i])) {
$this->i++;
}
}
private function peek(): ?string {
return $this->i < $this->len ? $this->s[$this->i] : null;
}
private function parseValue() {
$this->skipWs();
$c = $this->peek();
if ($c === null) throw new SnbtParseError('Unexpected end of input');
if ($c === '{') return $this->parseCompound();
if ($c === '[') return $this->parseList();
if ($c === '"' || $c === "'") return $this->parseString();
return $this->parseBareToken();
}
private function parseCompound(): array {
$this->expect('{');
$out = [];
$this->skipWs();
if ($this->peek() === '}') { $this->i++; return $out; }
while (true) {
$this->skipWs();
$key = $this->parseKey();
$this->skipWs();
$this->expect(':');
$val = $this->parseValue();
$out[$key] = $val;
$this->skipWs();
$c = $this->peek();
if ($c === ',') { $this->i++; continue; }
if ($c === '}') { $this->i++; break; }
throw new SnbtParseError("Expected , or } at offset {$this->i}");
}
return $out;
}
private function parseList(): array {
$this->expect('[');
// Typed array prefix: [I; ... ] [L; ... ] [B; ... ]
if (preg_match('/\G([ILB]);/', $this->s, $m, 0, $this->i)) {
$this->i += 2;
}
$out = [];
$this->skipWs();
if ($this->peek() === ']') { $this->i++; return $out; }
while (true) {
$out[] = $this->parseValue();
$this->skipWs();
$c = $this->peek();
if ($c === ',') { $this->i++; continue; }
if ($c === ']') { $this->i++; break; }
throw new SnbtParseError("Expected , or ] at offset {$this->i}");
}
return $out;
}
private function parseKey(): string {
$c = $this->peek();
if ($c === '"' || $c === "'") return $this->parseString();
$start = $this->i;
while ($this->i < $this->len) {
$ch = $this->s[$this->i];
if ($ch === ':' || ctype_space($ch)) break;
$this->i++;
}
return substr($this->s, $start, $this->i - $start);
}
private function parseString(): string {
$quote = $this->s[$this->i];
$this->i++;
$out = '';
while ($this->i < $this->len) {
$ch = $this->s[$this->i];
if ($ch === '\\') {
$this->i++;
$next = $this->peek();
$out .= match ($next) {
'n' => "\n", 't' => "\t", 'r' => "\r",
'\\', '"', "'" => $next,
default => $next,
};
$this->i++;
continue;
}
if ($ch === $quote) {
$this->i++;
return $out;
}
$out .= $ch;
$this->i++;
}
throw new SnbtParseError('Unterminated string');
}
private function parseBareToken() {
$start = $this->i;
while ($this->i < $this->len) {
$ch = $this->s[$this->i];
if ($ch === ',' || $ch === '}' || $ch === ']' || ctype_space($ch)) break;
$this->i++;
}
$tok = substr($this->s, $start, $this->i - $start);
// Numeric suffixes: 64b, 1s, 1l, 1f, 1d
if (preg_match('/^(-?\d+)([bslBSL])$/', $tok, $m)) {
return (int)$m[1];
}
if (preg_match('/^(-?\d*\.?\d+(?:[eE][-+]?\d+)?)([fdFD])$/', $tok, $m)) {
return (float)$m[1];
}
if (is_numeric($tok)) {
return strpos($tok, '.') !== false ? (float)$tok : (int)$tok;
}
if ($tok === 'true') return 1;
if ($tok === 'false') return 0;
return $tok;
}
private function expect(string $ch): void {
$this->skipWs();
if ($this->peek() !== $ch) {
throw new SnbtParseError("Expected '$ch' at offset {$this->i}");
}
$this->i++;
}
}
/* ---------------- Inventory shaping ---------------- */
function snbt_summarize_inventory(array $raw): array {
$items = [];
foreach ($raw as $entry) {
if (!is_array($entry)) continue;
$item = snbt_describe_item($entry);
if ($item !== null) $items[] = $item;
}
usort($items, fn($a, $b) => ($a['slot'] ?? 0) <=> ($b['slot'] ?? 0));
return ['items' => $items];
}
/**
* Turn a single inventory slot entry into a readable summary.
*
* Handles BOTH formats:
* OLD (<= 1.20.4): {Slot:0b, id:"...", Count:64b, tag:{Damage:5, Enchantments:[...]}}
* NEW (>= 1.20.5): {slot:0, id:"...", count:64, components:{"minecraft:damage":5, ...}}
*
* We accept either casing of Slot/Slot, Count/count.
*/
function snbt_describe_item(array $entry): ?array {
if (!isset($entry['id'])) return null;
$id = (string)$entry['id'];
$count = 1;
if (isset($entry['count'])) $count = (int)$entry['count'];
elseif (isset($entry['Count'])) $count = (int)$entry['Count'];
$slot = null;
if (isset($entry['slot'])) $slot = (int)$entry['slot'];
elseif (isset($entry['Slot'])) $slot = (int)$entry['Slot'];
$customName = null;
$enchantments = [];
$damage = null;
$maxDamage = null;
$contained = null;
// Old format: tag:{...}
// New format: components:{"minecraft:...": ...}
$tag = $entry['tag'] ?? $entry['components'] ?? null;
if (is_array($tag)) {
// Custom name
// Old: tag.display.Name = '{"text":"..."}'
// New: components."minecraft:custom_name" = '{"text":"..."}' or a plain string
$nameRaw = null;
if (isset($tag['display']['Name'])) {
$nameRaw = (string)$tag['display']['Name'];
} elseif (isset($tag['minecraft:custom_name'])) {
$nameRaw = is_string($tag['minecraft:custom_name'])
? $tag['minecraft:custom_name']
: json_encode($tag['minecraft:custom_name']);
}
if ($nameRaw !== null) {
// Strip JSON structure and legacy color codes
$clean = preg_replace('/\{"text":"([^"]*)".*/', '$1', $nameRaw);
$clean = preg_replace('/ยง./u', '', $clean);
$clean = trim($clean, '"\' ');
if ($clean !== '') $customName = $clean;
}
// Enchantments
// Old: tag.Enchantments = [{id:"minecraft:sharpness", lvl:5}]
// New: components."minecraft:enchantments" = {"minecraft:sharpness": 5}
if (isset($tag['Enchantments']) && is_array($tag['Enchantments'])) {
foreach ($tag['Enchantments'] as $en) {
if (!is_array($en)) continue;
$enchantments[] = [
'id' => (string)($en['id'] ?? 'unknown'),
'level' => (int)($en['lvl'] ?? 0),
];
}
} elseif (isset($tag['minecraft:enchantments']) && is_array($tag['minecraft:enchantments'])) {
foreach ($tag['minecraft:enchantments'] as $enId => $lvl) {
$enchantments[] = [
'id' => (string)$enId,
'level' => (int)$lvl,
];
}
}
// Damage / durability
// Old: tag.Damage = 42, tag.MaxDamage (rare)
// New: components."minecraft:damage" = 42, components."minecraft:max_damage" = 1561
if (isset($tag['Damage'])) $damage = (int)$tag['Damage'];
elseif (isset($tag['minecraft:damage'])) $damage = (int)$tag['minecraft:damage'];
if (isset($tag['MaxDamage'])) $maxDamage = (int)$tag['MaxDamage'];
elseif (isset($tag['minecraft:max_damage'])) $maxDamage = (int)$tag['minecraft:max_damage'];
// Container contents
// Old shulker: tag.BlockEntityTag.Items = [...]
// New shulker: components."minecraft:container" = [{slot:0, item:{id:..., count:...}}, ...]
// Bundle: components."minecraft:bundle_contents" = [{id:..., count:...}, ...]
$containerRaw = null;
if (isset($tag['BlockEntityTag']['Items']) && is_array($tag['BlockEntityTag']['Items'])) {
$containerRaw = $tag['BlockEntityTag']['Items'];
} elseif (isset($tag['minecraft:container']) && is_array($tag['minecraft:container'])) {
// Each entry: {slot:0b, item:{id:"...", count:N}}
$containerRaw = [];
foreach ($tag['minecraft:container'] as $cEntry) {
if (!is_array($cEntry)) continue;
$sub = $cEntry['item'] ?? null;
if (!is_array($sub)) continue;
$sub['slot'] = $cEntry['slot'] ?? null;
$containerRaw[] = $sub;
}
} elseif (isset($tag['minecraft:bundle_contents']) && is_array($tag['minecraft:bundle_contents'])) {
$containerRaw = $tag['minecraft:bundle_contents'];
}
if ($containerRaw !== null) {
$contained = [];
foreach ($containerRaw as $sub) {
if (!is_array($sub)) continue;
$d = snbt_describe_item($sub);
if ($d !== null) $contained[] = $d;
}
// Bundles don't carry a slot on each sub-item โ assign sequential
$needsSlots = false;
foreach ($contained as $c) { if ($c['slot'] === null) { $needsSlots = true; break; } }
if ($needsSlots) {
foreach ($contained as $idx => &$c) { $c['slot'] = $idx; }
unset($c);
}
usort($contained, fn($a, $b) => ($a['slot'] ?? 0) <=> ($b['slot'] ?? 0));
}
}
return [
'slot' => $slot,
'id' => $id,
'name' => snbt_pretty_item_name($id),
'count' => $count,
'custom_name' => $customName,
'enchantments' => $enchantments,
'damage' => $damage,
'max_damage' => $maxDamage,
'contains' => $contained,
];
}
function snbt_pretty_item_name(string $id): string {
$short = strpos($id, ':') !== false ? substr($id, strpos($id, ':') + 1) : $id;
return ucwords(str_replace('_', ' ', $short));
}
โ Back