protolib 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
protolib/core.py ADDED
@@ -0,0 +1,884 @@
1
+ """
2
+ protolib/core.py
3
+
4
+ Motor central de la librería. La clase Protocol carga un protocol.json
5
+ (o un dict de tipos equivalente) y sabe:
6
+
7
+ protocol.read_type(type_def, reader, scope, fields) -> valor python
8
+ protocol.write_type(type_def, value, writer, scope, fields)
9
+
10
+ protocol.parse_packet(state, direction, data: bytes) -> ParsedPacket
11
+ protocol.serialize_packet(state, direction, name, params: dict) -> bytes
12
+
13
+ Tipos compuestos soportados (definidos como ["tipoBase", opciones]):
14
+ container - lista ordenada de campos con nombre
15
+ array - lista homogénea, con count fijo, countType, o
16
+ count-referenciando-otro-campo
17
+ switch - elige el tipo según el valor de otro campo (compareTo)
18
+ mapper - traduce un entero crudo a un nombre simbólico (y viceversa)
19
+ option - valor opcional precedido por un bool ("presente?")
20
+ bitfield - empaqueta/desempaqueta sub-campos de N bits cada uno
21
+ bitflags - entero interpretado como conjunto de flags con nombre
22
+ buffer - bytes crudos, con longitud fija o por countType/count
23
+ pstring - string con longitud prefijada por countType (varint, u16, etc)
24
+ entityMetadataLoop - lista de entradas hasta encontrar un terminador
25
+ topBitSetTerminatedArray - lista que termina cuando el bit más alto del
26
+ primer byte leído en una entrada NO está seteado
27
+ (patrón de listas LEB128-like)
28
+
29
+ Los tipos primitivos (varint, i32, bool, cstring, etc.) viven en
30
+ primitives.py y se resuelven por nombre.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from dataclasses import dataclass, field
36
+ from typing import Any, Callable
37
+
38
+ from .io import Reader, Writer
39
+ from .primitives import PRIMITIVES
40
+ from .conditions import eval_condition
41
+ from .errors import (
42
+ UnknownTypeError,
43
+ InvalidTypeDefinition,
44
+ SwitchCaseNotFound,
45
+ )
46
+ from .loader import load_protocol_dict
47
+
48
+ TypeDef = Any # str | [str, dict] -- no tipamos más estricto por simplicidad
49
+
50
+
51
+ def resolve_field_path(path: Any, fields: dict, root: dict | None, parent: dict | None) -> Any:
52
+ """
53
+ Resuelve un path de campo estilo node-protodef (utils.js:getField).
54
+
55
+ Soporta:
56
+ "nombre" -> fields["nombre"] (campo del container actual)
57
+ "../nombre" -> parent["nombre"] (un nivel arriba)
58
+ "/nombre" -> root["nombre"] (desde la raíz absoluta)
59
+
60
+ Además, paridad con node-protodef: `count`/`countFor` puede ser
61
+ directamente un entero literal (tamaño fijo), no solo un path de campo
62
+ -- p.ej. ["array", {"count": 4, "type": "i32"}]. En ese caso se
63
+ devuelve tal cual, sin tratarlo como string.
64
+
65
+ Nota: node-protodef sube niveles siguiendo un puntero '..' que cada
66
+ container encadena a su padre real, así que "../../x" puede subir N
67
+ niveles. Este port no mantiene esa cadena completa (solo fields/root/
68
+ parent explícitos), así que "../../x" y superior caen a "root" -- cubre
69
+ el caso real usado en protocolos existentes (como mucho un "../").
70
+ """
71
+ if isinstance(path, int):
72
+ return path
73
+
74
+ if path.startswith("/"):
75
+ context = root if root is not None else fields
76
+ segments = [s for s in path.split("/") if s != ""]
77
+ elif path.startswith("../"):
78
+ up_count = 0
79
+ rest = path
80
+ while rest.startswith("../"):
81
+ up_count += 1
82
+ rest = rest[3:]
83
+ context = parent if up_count == 1 else (root if root is not None else parent)
84
+ segments = [s for s in rest.split("/") if s != ""]
85
+ else:
86
+ context = fields
87
+ segments = [s for s in path.split("/") if s != ""]
88
+
89
+ current = context
90
+ for seg in segments:
91
+ if current is None:
92
+ return None
93
+ current = current.get(seg) if isinstance(current, dict) else None
94
+ return current
95
+
96
+
97
+ def substitute_type_args(type_def: Any, type_args: dict) -> Any:
98
+ """
99
+ Sustituye placeholders "$nombre" dentro de una definición de tipo por el
100
+ valor correspondiente en `type_args`, recorriendo recursivamente dicts y
101
+ listas. Paridad con node-protodef (protodef.js: findArgs/setField/
102
+ produceArgs, usado por extendType).
103
+
104
+ Esto es lo que permite tipos nombrados "parametrizables" como:
105
+
106
+ entityMetadataItem:
107
+ switch:
108
+ compareTo: $compareTo
109
+ fields: {...}
110
+
111
+ invocados en el protocolo como:
112
+
113
+ type:
114
+ - entityMetadataItem
115
+ - compareTo: type
116
+
117
+ Al invocarse así, la definición default de entityMetadataItem (que tiene
118
+ el placeholder "$compareTo") se clona y el placeholder se reemplaza por
119
+ el valor real pasado en el punto de uso ("type" en este ejemplo) --
120
+ resultando en un switch efectivo con compareTo="type".
121
+
122
+ Un placeholder sin valor provisto en `type_args` (o `type_args=None`)
123
+ se deja tal cual el string "$nombre" -- igual que node-protodef, que
124
+ solo sustituye los args que sí vinieron en typeArgs.
125
+ """
126
+ if isinstance(type_def, str):
127
+ if type_def.startswith("$") and isinstance(type_args, dict):
128
+ key = type_def[1:]
129
+ if key in type_args:
130
+ return type_args[key]
131
+ return type_def
132
+ if isinstance(type_def, list):
133
+ return [substitute_type_args(item, type_args) for item in type_def]
134
+ if isinstance(type_def, dict):
135
+ return {k: substitute_type_args(v, type_args) for k, v in type_def.items()}
136
+ return type_def
137
+
138
+
139
+ @dataclass
140
+ class ParsedPacket:
141
+ name: str
142
+ params: dict[str, Any]
143
+ bytes_read: int
144
+
145
+
146
+ @dataclass
147
+ class Scope:
148
+ """Representa un 'state.direction' (p.ej. play.toClient) con sus tipos propios,
149
+ que tienen prioridad sobre los tipos globales del protocolo."""
150
+
151
+ types: dict[str, TypeDef] = field(default_factory=dict)
152
+
153
+
154
+ class Protocol:
155
+ """
156
+ Carga y representa un protocol.json completo.
157
+
158
+ Estructura esperada del dict de entrada (mismo formato que
159
+ node-minecraft-protocol / node-protodef):
160
+
161
+ {
162
+ "types": { "<nombre>": <typeDef>, ... },
163
+ "<state>": {
164
+ "toClient": { "types": { ... } },
165
+ "toServer": { "types": { ... } }
166
+ },
167
+ ...
168
+ }
169
+
170
+ Los nombres de tipo se resuelven primero contra el scope local
171
+ (state.direction.types) y si no aparecen ahí, contra los tipos globales.
172
+
173
+ `protocol_source` acepta:
174
+ - un dict ya parseado (formato node-protodef clásico)
175
+ - una ruta a archivo .json, .yml o .yaml
176
+ - un string con contenido JSON o YAML en memoria
177
+
178
+ Los archivos .yml/.yaml admiten una sintaxis "shorthand" más legible
179
+ (ver protodef/loader.py), que se traduce automáticamente al formato
180
+ interno ["tipo", opciones]. El JSON de minecraft-data funciona sin
181
+ cambios.
182
+ """
183
+
184
+ def __init__(self, protocol_source: dict[str, Any] | str, *, fmt: str | None = None):
185
+ protocol_json = load_protocol_dict(protocol_source, fmt=fmt)
186
+ self.raw = protocol_json
187
+ self.global_types: dict[str, TypeDef] = protocol_json.get("types", {})
188
+ self._scopes: dict[tuple[str, str], Scope] = {}
189
+
190
+ for state_name, state_val in protocol_json.items():
191
+ if state_name == "types" or not isinstance(state_val, dict):
192
+ continue
193
+ for direction in ("toClient", "toServer"):
194
+ if direction in state_val:
195
+ self._scopes[(state_name, direction)] = Scope(
196
+ types=state_val[direction].get("types", {})
197
+ )
198
+
199
+ self._composite_handlers: dict[str, Callable] = {
200
+ "container": self._read_container,
201
+ "array": self._read_array,
202
+ "count": self._read_count,
203
+ "switch": self._read_switch,
204
+ "mapper": self._read_mapper,
205
+ "option": self._read_option,
206
+ "bitfield": self._read_bitfield,
207
+ "bitflags": self._read_bitflags,
208
+ "buffer": self._read_buffer,
209
+ "pstring": self._read_pstring,
210
+ "entityMetadataLoop": self._read_entity_metadata_loop,
211
+ "topBitSetTerminatedArray": self._read_top_bit_set_terminated_array,
212
+ "cstring": self._read_cstring_encoded,
213
+ }
214
+ self._composite_write_handlers: dict[str, Callable] = {
215
+ "container": self._write_container,
216
+ "array": self._write_array,
217
+ "count": self._write_count,
218
+ "switch": self._write_switch,
219
+ "mapper": self._write_mapper,
220
+ "option": self._write_option,
221
+ "bitfield": self._write_bitfield,
222
+ "bitflags": self._write_bitflags,
223
+ "buffer": self._write_buffer,
224
+ "pstring": self._write_pstring,
225
+ "entityMetadataLoop": self._write_entity_metadata_loop,
226
+ "topBitSetTerminatedArray": self._write_top_bit_set_terminated_array,
227
+ "cstring": self._write_cstring_encoded,
228
+ }
229
+
230
+ # -------------------------------------------------------------------
231
+ # Resolución de nombres de tipo
232
+ # -------------------------------------------------------------------
233
+
234
+ def get_scope(self, state: str, direction: str) -> Scope:
235
+ try:
236
+ return self._scopes[(state, direction)]
237
+ except KeyError:
238
+ raise UnknownTypeError(f"{state}.{direction} (state/direction no definido)")
239
+
240
+ def _resolve_named_type(self, name: str, scope: Scope | None) -> TypeDef | None:
241
+ if scope is not None and name in scope.types:
242
+ return scope.types[name]
243
+ if name in self.global_types:
244
+ return self.global_types[name]
245
+ return None
246
+
247
+ # -------------------------------------------------------------------
248
+ # Lectura
249
+ # -------------------------------------------------------------------
250
+
251
+ def read_type(self, type_def: TypeDef, r: Reader, scope: Scope | None,
252
+ fields: dict[str, Any], root: dict | None = None,
253
+ parent: dict | None = None) -> Any:
254
+ if isinstance(type_def, str):
255
+ if type_def in PRIMITIVES:
256
+ return PRIMITIVES[type_def].read(r)
257
+ resolved = self._resolve_named_type(type_def, scope)
258
+ if resolved is None:
259
+ raise UnknownTypeError(type_def)
260
+ return self.read_type(resolved, r, scope, fields, root, parent)
261
+
262
+ if isinstance(type_def, list) and len(type_def) == 2:
263
+ base, opts = type_def
264
+ handler = self._composite_handlers.get(base)
265
+ if handler is not None:
266
+ return handler(opts, r, scope, fields, root, parent)
267
+ # `base` no es un tipo base compuesto (container/switch/array/...)
268
+ # -- puede ser un tipo NOMBRADO parametrizable, invocado como
269
+ # [nombre, typeArgs], p.ej. [entityMetadataItem, {compareTo: type}].
270
+ # Paridad con node-protodef (protodef.js: extendType/produceArgs):
271
+ # se resuelve la definición default del tipo nombrado y se
272
+ # sustituyen los placeholders "$arg" por los typeArgs dados acá.
273
+ named = self._resolve_named_type(base, scope)
274
+ if named is None:
275
+ raise UnknownTypeError(f"(tipo base compuesto) {base}")
276
+ substituted = substitute_type_args(named, opts if isinstance(opts, dict) else None)
277
+ return self.read_type(substituted, r, scope, fields, root, parent)
278
+
279
+ raise InvalidTypeDefinition(type_def)
280
+
281
+ # ---- container ------------------------------------------------------
282
+
283
+ def _read_container(self, opts: list[dict], r: Reader, scope: Scope,
284
+ fields: dict, root: dict | None, parent: dict | None,
285
+ *, push_level: bool = True) -> dict:
286
+ result: dict[str, Any] = {}
287
+ effective_root = root if root is not None else result
288
+ # `push_level` decide si ESTE container pasa a ser el `parent`
289
+ # ("..") que ven sus propios sub-campos. Un container normal
290
+ # (resuelto por nombre, campo con nombre, etc.) sí empuja nivel --
291
+ # así funciona node-protodef. La excepción es un container usado
292
+ # directamente como item_type inline de un array: ahí node-protodef
293
+ # NO empuja contexto (el array es transparente para ".."), así que
294
+ # "../campo" dentro de un item de array debe saltar ese item y
295
+ # resolver contra el parent real que el array mismo tenía (p.ej.
296
+ # el container que contiene el array, no el item con `uuid`).
297
+ child_parent = result if push_level else parent
298
+ for f in opts:
299
+ if "condition" in f:
300
+ if not eval_condition(f["condition"], result, effective_root, fields):
301
+ continue
302
+ value = self.read_type(f["type"], r, scope, result, effective_root, child_parent)
303
+ if f.get("anon"):
304
+ if isinstance(value, dict):
305
+ result.update(value)
306
+ else:
307
+ result[f["name"]] = value
308
+ return result
309
+
310
+ def _write_container(self, opts: list[dict], value: dict, w: Writer, scope: Scope,
311
+ fields: dict, root: dict | None, parent: dict | None,
312
+ *, push_level: bool = True) -> None:
313
+ data = value or {}
314
+ effective_root = root if root is not None else data
315
+ child_parent = data if push_level else parent
316
+ for f in opts:
317
+ if "condition" in f:
318
+ if not eval_condition(f["condition"], data, effective_root, fields):
319
+ continue
320
+ if f.get("anon"):
321
+ self.write_type(f["type"], data, w, scope, data, effective_root, child_parent)
322
+ else:
323
+ self.write_type(f["type"], data.get(f["name"]), w, scope, data, effective_root, child_parent)
324
+
325
+ # ---- array ------------------------------------------------------------
326
+
327
+ def _read_array(self, opts: dict, r: Reader, scope: Scope,
328
+ fields: dict, root, parent) -> list:
329
+ if "count" in opts:
330
+ count = resolve_field_path(opts["count"], fields, root, parent)
331
+ if not isinstance(count, int) or count < 0:
332
+ raise InvalidTypeDefinition(
333
+ f"array: 'count' ({opts['count']!r}) resolvió a {count!r}, "
334
+ f"se esperaba un entero >= 0 -- revisá que el campo exista "
335
+ f"y que el path (../, /) apunte al nivel correcto"
336
+ )
337
+ elif "countType" in opts:
338
+ count = self.read_type(opts["countType"], r, scope, fields, root, parent)
339
+ else:
340
+ raise InvalidTypeDefinition("array requiere 'count' o 'countType'")
341
+
342
+ item_type = opts["type"]
343
+ return [self._read_array_item(item_type, r, scope, fields, root, parent) for _ in range(count)]
344
+
345
+ def _read_array_item(self, item_type, r: Reader, scope: Scope,
346
+ fields: dict, root, parent) -> Any:
347
+ # Un container inline (["container", opts], literal dentro del
348
+ # array.type -- no un tipo nombrado como "packet_player_info") no
349
+ # debe empujar su propio nivel de parent: ver comentario en
350
+ # _read_container. Cualquier otro item_type (tipo nombrado, switch,
351
+ # primitivo) se comporta exactamente igual que antes.
352
+ if isinstance(item_type, list) and len(item_type) == 2 and item_type[0] == "container":
353
+ return self._read_container(item_type[1], r, scope, fields, root, parent, push_level=False)
354
+ return self.read_type(item_type, r, scope, fields, root, parent)
355
+
356
+ def _write_array(self, opts: dict, value: list, w: Writer, scope: Scope,
357
+ fields: dict, root, parent) -> None:
358
+ items = value or []
359
+ if "countType" in opts:
360
+ self.write_type(opts["countType"], len(items), w, scope, fields, root, parent)
361
+ # si usa "count" (referencia a otro campo), el caller debe haber
362
+ # escrito ese campo antes (responsabilidad del container que arma 'fields')
363
+ item_type = opts["type"]
364
+ for item in items:
365
+ self._write_array_item(item_type, item, w, scope, fields, root, parent)
366
+
367
+ def _write_array_item(self, item_type, item, w: Writer, scope: Scope,
368
+ fields: dict, root, parent) -> None:
369
+ if isinstance(item_type, list) and len(item_type) == 2 and item_type[0] == "container":
370
+ self._write_container(item_type[1], item, w, scope, fields, root, parent, push_level=False)
371
+ return
372
+ self.write_type(item_type, item, w, scope, fields, root, parent)
373
+
374
+ # ---- count (length-prefix "separado", declarado como campo hermano) -------
375
+ # Uso típico: un container donde el prefijo de longitud de un array/buffer
376
+ # NO está pegado a ese array (caso normal de countType), sino que es un
377
+ # campo propio en otra posición del container, y el array referencia ese
378
+ # campo por nombre via su propio "count". El tipo `count` en sí, al leer,
379
+ # simplemente lee un entero (typeArgs.type); al escribir, IGNORA el valor
380
+ # que se le pase y escribe len(getField(countFor)) -- igual que
381
+ # node-protodef structures.js: readCount/writeCount.
382
+ #
383
+ # opts: {"type": "u8"|"varint"|..., "countFor": "<path al campo, admite ../ y />"}
384
+
385
+ def _read_count(self, opts: dict, r: Reader, scope: Scope,
386
+ fields: dict, root, parent) -> Any:
387
+ return self.read_type(opts["type"], r, scope, fields, root, parent)
388
+
389
+ def _write_count(self, opts: dict, value: Any, w: Writer, scope: Scope,
390
+ fields: dict, root, parent) -> None:
391
+ target = resolve_field_path(opts["countFor"], fields, root, parent)
392
+ length = len(target) if target is not None else 0
393
+ self.write_type(opts["type"], length, w, scope, fields, root, parent)
394
+
395
+ # ---- switch -------------------------------------------------------------
396
+
397
+ def _resolve_compare_value(self, opts: dict, fields: dict, root, parent) -> Any:
398
+ # node-protodef (compiler-conditional.js): el switch compara contra
399
+ # `compareTo` (path a un campo ya leído/escrito) O contra
400
+ # `compareToValue` (un literal fijo, sin indirección -- p.ej. útil
401
+ # para elegir tipo según el nombre del propio packet, que ya viene
402
+ # calculado desde afuera). Solo uno de los dos debería estar presente.
403
+ if "compareToValue" in opts:
404
+ return opts["compareToValue"]
405
+ compare_to = opts["compareTo"]
406
+ if compare_to.startswith("fields."):
407
+ return eval_condition(compare_to, fields, root, parent)
408
+ if compare_to in fields:
409
+ return fields[compare_to]
410
+ # Paths estilo node-protodef (utils.js:getField): "../campo" sube al
411
+ # container padre, "/campo" es absoluto desde la raíz. Antes esto
412
+ # caía a eval_condition, que solo entiende sintaxis "$parent.campo"
413
+ # -- nunca "../campo" -- así que un compareTo con ".." siempre
414
+ # resolvía a None (bug real detrás de SwitchCaseNotFound con
415
+ # compareTo="../algo"). resolve_field_path es quien sabe navegar
416
+ # "../" y "/", así que lo usamos primero para esos casos.
417
+ if compare_to.startswith("../") or compare_to.startswith("/"):
418
+ return resolve_field_path(compare_to, fields, root, parent)
419
+ try:
420
+ return eval_condition(compare_to, fields, root, parent)
421
+ except Exception:
422
+ return None
423
+
424
+ def _resolve_switch_case(self, opts: dict, compare_val: Any, root) -> TypeDef | None:
425
+ case_key = str(compare_val) if not isinstance(compare_val, str) else compare_val
426
+ case_type = opts["fields"].get(case_key, opts["fields"].get(compare_val))
427
+ if case_type is not None:
428
+ return case_type
429
+ # Keys que empiezan con "/" se resuelven contra el root context, no
430
+ # como comparación de string -- paridad con compiler-conditional.js:
431
+ # `if (val.startsWith('/')) val = 'ctx.' + val.slice(1)`. Recorremos
432
+ # las keys del switch buscando una cuyo valor-resuelto-en-root matchee.
433
+ for key, type_for_key in opts["fields"].items():
434
+ if key.startswith("/"):
435
+ root_val = resolve_field_path(key, {}, root, None)
436
+ if root_val == compare_val:
437
+ return type_for_key
438
+ return None
439
+
440
+ def _identify_compare_ref(self, opts: dict) -> str:
441
+ return opts.get("compareToValue", opts.get("compareTo"))
442
+
443
+ def _read_switch(self, opts: dict, r: Reader, scope: Scope,
444
+ fields: dict, root, parent) -> Any:
445
+ compare_val = self._resolve_compare_value(opts, fields, root, parent)
446
+ case_type = self._resolve_switch_case(opts, compare_val, root)
447
+ if case_type is None:
448
+ if "default" in opts:
449
+ return self.read_type(opts["default"], r, scope, fields, root, parent)
450
+ raise SwitchCaseNotFound(self._identify_compare_ref(opts), compare_val)
451
+ return self.read_type(case_type, r, scope, fields, root, parent)
452
+
453
+ def _write_switch(self, opts: dict, value: Any, w: Writer, scope: Scope,
454
+ fields: dict, root, parent) -> None:
455
+ compare_val = self._resolve_compare_value(opts, fields, root, parent)
456
+ case_type = self._resolve_switch_case(opts, compare_val, root)
457
+ if case_type is None:
458
+ if "default" in opts:
459
+ return self.write_type(opts["default"], value, w, scope, fields, root, parent)
460
+ raise SwitchCaseNotFound(self._identify_compare_ref(opts), compare_val)
461
+ return self.write_type(case_type, value, w, scope, fields, root, parent)
462
+
463
+ # ---- mapper (entero <-> nombre simbólico) --------------------------------
464
+
465
+ def _read_mapper(self, opts: dict, r: Reader, scope: Scope,
466
+ fields: dict, root, parent) -> Any:
467
+ raw = self.read_type(opts["type"], r, scope, fields, root, parent)
468
+ mappings = opts["mappings"]
469
+ # normalizamos las keys del mapping (que pueden venir como "0x00",
470
+ # "0x1f", "31", etc.) a entero, para no depender del formato exacto
471
+ # con el que esté escrito el protocol.json
472
+ for key, mapped_name in mappings.items():
473
+ key_int = int(key, 16) if key.lower().startswith("0x") else int(key)
474
+ if key_int == raw:
475
+ return mapped_name
476
+ return raw # sin mapping conocido: se devuelve el valor crudo
477
+
478
+ def _write_mapper(self, opts: dict, value: Any, w: Writer, scope: Scope,
479
+ fields: dict, root, parent) -> None:
480
+ mappings = opts["mappings"]
481
+ if isinstance(value, str):
482
+ numeric = None
483
+ for k, v in mappings.items():
484
+ if v == value:
485
+ numeric = int(k, 16) if k.startswith("0x") else int(k)
486
+ break
487
+ if numeric is None:
488
+ raise InvalidTypeDefinition(
489
+ f"mapper: no se encontró mapping inverso para {value!r}"
490
+ )
491
+ else:
492
+ numeric = value
493
+ self.write_type(opts["type"], numeric, w, scope, fields, root, parent)
494
+
495
+ # ---- option (presente si un bool previo es true) -------------------------
496
+
497
+ def _read_option(self, opts: TypeDef, r: Reader, scope: Scope,
498
+ fields: dict, root, parent) -> Any:
499
+ present = PRIMITIVES["bool"].read(r)
500
+ if not present:
501
+ return None
502
+ return self.read_type(opts, r, scope, fields, root, parent)
503
+
504
+ def _write_option(self, opts: TypeDef, value: Any, w: Writer, scope: Scope,
505
+ fields: dict, root, parent) -> None:
506
+ present = value is not None
507
+ PRIMITIVES["bool"].write(present, w)
508
+ if present:
509
+ self.write_type(opts, value, w, scope, fields, root, parent)
510
+
511
+ # ---- bitfield (sub-campos empaquetados en N bits) -------------------------
512
+
513
+ def _read_bitfield(self, opts: list[dict], r: Reader, scope: Scope,
514
+ fields: dict, root, parent) -> dict:
515
+ # node-protodef (structures.js:readBitField) NO exige que la suma de
516
+ # bits sea múltiplo de 8: si sobran bits al final, se rellenan con
517
+ # padding hasta completar el último byte. Ya no lanzamos error acá;
518
+ # simplemente redondeamos hacia arriba al byte siguiente.
519
+ total_bits = sum(f["size"] for f in opts)
520
+ num_bytes = (total_bits + 7) // 8
521
+ pad_bits = num_bytes * 8 - total_bits
522
+ raw_bytes = r.read_bytes(num_bytes)
523
+ big = int.from_bytes(raw_bytes, "big")
524
+
525
+ result: dict[str, Any] = {}
526
+ bits_left = total_bits + pad_bits
527
+ for f in opts:
528
+ bits_left -= f["size"]
529
+ mask = (1 << f["size"]) - 1
530
+ val = (big >> bits_left) & mask
531
+ if f.get("signed") and val >= (1 << (f["size"] - 1)):
532
+ val -= 1 << f["size"]
533
+ result[f["name"]] = val
534
+ return result
535
+
536
+ def _write_bitfield(self, opts: list[dict], value: dict, w: Writer, scope: Scope,
537
+ fields: dict, root, parent) -> None:
538
+ # Mismo criterio que _read_bitfield: si el total de bits no completa
539
+ # un byte, se rellena (padding) desplazando a la izquierda el sobrante
540
+ # -- paridad con node-protodef: `buffer[offset++] = toWrite << (8 - bits)`
541
+ # en el último byte cuando quedan bits < 8 sin usar.
542
+ total_bits = sum(f["size"] for f in opts)
543
+ num_bytes = (total_bits + 7) // 8
544
+ pad_bits = num_bytes * 8 - total_bits
545
+ big = 0
546
+ for f in opts:
547
+ v = value.get(f["name"], 0) & ((1 << f["size"]) - 1)
548
+ big = (big << f["size"]) | v
549
+ big <<= pad_bits
550
+ w.write_bytes(big.to_bytes(num_bytes, "big"))
551
+
552
+ # ---- bitflags (entero como set de flags con nombre) ------------------------
553
+
554
+ def _read_bitflags(self, opts: dict, r: Reader, scope: Scope,
555
+ fields: dict, root, parent) -> dict[str, Any]:
556
+ raw = self.read_type(opts["type"], r, scope, fields, root, parent)
557
+ flags_def = opts["flags"]
558
+ shift = opts.get("shift", False)
559
+
560
+ # flags como dict: {"nombre": bitmask, ...} o, si shift=True,
561
+ # {"nombre": posiciónDeBit, ...} -- paridad con spec oficial
562
+ # (utils.md: "shift: Specify if flags is an object and holds bit
563
+ # positions as values opposed to a bitmask").
564
+ if isinstance(flags_def, dict):
565
+ result: dict[str, Any] = {}
566
+ for flag_name, raw_mask in flags_def.items():
567
+ mask = (1 << raw_mask) if shift else raw_mask
568
+ result[flag_name] = bool(raw & mask)
569
+ result["_value"] = raw
570
+ return result
571
+
572
+ # flags como lista posicional (cada uno ocupa 1 bit, LSB primero,
573
+ # o MSB primero si big=True) -- paridad con spec oficial.
574
+ flag_names: list[str] = flags_def
575
+ big_endian = opts.get("big", False)
576
+ result: dict[str, Any] = {}
577
+ names = list(reversed(flag_names)) if big_endian else flag_names
578
+ for i, flag_name in enumerate(names):
579
+ if flag_name is None:
580
+ continue
581
+ result[flag_name] = bool((raw >> i) & 1)
582
+ # Paridad con node-protodef: el valor crudo siempre viaja en '_value',
583
+ # además de cada flag individual con su propio nombre.
584
+ result["_value"] = raw
585
+ return result
586
+
587
+ def _write_bitflags(self, opts: dict, value: dict, w: Writer, scope: Scope,
588
+ fields: dict, root, parent) -> None:
589
+ flags_def = opts["flags"]
590
+ shift = opts.get("shift", False)
591
+
592
+ # Spec oficial: al escribir, el valor esperado viene envuelto como
593
+ # {"flags": {...}}. Por retrocompatibilidad con protocolos que ya
594
+ # dependían de este port pasando el dict de flags "pelado" (sin
595
+ # envolver), aceptamos ambas formas.
596
+ flag_values = value.get("flags", value) if isinstance(value, dict) else {}
597
+
598
+ if isinstance(flags_def, dict):
599
+ raw = 0
600
+ for flag_name, raw_mask in flags_def.items():
601
+ mask = (1 << raw_mask) if shift else raw_mask
602
+ if flag_values.get(flag_name):
603
+ raw |= mask
604
+ self.write_type(opts["type"], raw, w, scope, fields, root, parent)
605
+ return
606
+
607
+ flag_names: list[str] = flags_def
608
+ big_endian = opts.get("big", False)
609
+ names = list(reversed(flag_names)) if big_endian else flag_names
610
+ raw = 0
611
+ for i, flag_name in enumerate(names):
612
+ if flag_name is None:
613
+ continue
614
+ if flag_values.get(flag_name):
615
+ raw |= (1 << i)
616
+ self.write_type(opts["type"], raw, w, scope, fields, root, parent)
617
+
618
+ # ---- buffer (bytes crudos) ------------------------------------------------
619
+
620
+ def _read_buffer(self, opts: dict, r: Reader, scope: Scope,
621
+ fields: dict, root, parent) -> bytes:
622
+ if "count" in opts:
623
+ count = resolve_field_path(opts["count"], fields, root, parent)
624
+ if not isinstance(count, int) or count < 0:
625
+ raise InvalidTypeDefinition(
626
+ f"buffer: 'count' ({opts['count']!r}) resolvió a {count!r}, "
627
+ f"se esperaba un entero >= 0 -- revisá que el campo exista "
628
+ f"y que el path (../, /) apunte al nivel correcto"
629
+ )
630
+ elif "countType" in opts:
631
+ count = self.read_type(opts["countType"], r, scope, fields, root, parent)
632
+ elif opts.get("rest"):
633
+ count = r.remaining
634
+ else:
635
+ raise InvalidTypeDefinition("buffer requiere 'count', 'countType' o 'rest'")
636
+ return r.read_bytes(count)
637
+
638
+ def _write_buffer(self, opts: dict, value: bytes, w: Writer, scope: Scope,
639
+ fields: dict, root, parent) -> None:
640
+ data = value or b""
641
+ if "countType" in opts:
642
+ self.write_type(opts["countType"], len(data), w, scope, fields, root, parent)
643
+ elif "count" in opts and isinstance(opts["count"], int):
644
+ # count fijo (entero literal, no un path a otro campo): a
645
+ # diferencia de countType, acá NADIE escribe un largo antes
646
+ # -- el framing del protocolo asume que este campo ocupa
647
+ # EXACTAMENTE ese tanto de bytes. Si `data` viene más corto
648
+ # o más largo, escribir tal cual dejaría el siguiente campo
649
+ # desalineado en quien reciba el paquete, sin ningún error
650
+ # visible acá. Mejor fallar ruidoso que corromper el framing.
651
+ if len(data) != opts["count"]:
652
+ raise InvalidTypeDefinition(
653
+ f"buffer: se esperaban exactamente {opts['count']} bytes "
654
+ f"(count fijo), pero el valor tiene {len(data)} -- si el "
655
+ f"tamaño puede variar, generá un primitivo dedicado con "
656
+ f"padding explícito (ver make_fixed_buffer en "
657
+ f"primitives.py) en vez de 'buffer' genérico"
658
+ )
659
+ w.write_bytes(data)
660
+
661
+ # ---- pstring (string con longitud prefijada configurable) -----------------
662
+
663
+ def _read_pstring(self, opts: dict, r: Reader, scope: Scope,
664
+ fields: dict, root, parent) -> str:
665
+ count_type = opts.get("countType", "varint")
666
+ encoding = opts.get("encoding", "utf-8")
667
+ if "count" in opts:
668
+ length = resolve_field_path(opts["count"], fields, root, parent)
669
+ if not isinstance(length, int) or length < 0:
670
+ raise InvalidTypeDefinition(
671
+ f"pstring: 'count' ({opts['count']!r}) resolvió a {length!r}, "
672
+ f"se esperaba un entero >= 0 -- revisá que el campo exista "
673
+ f"y que el path (../, /) apunte al nivel correcto"
674
+ )
675
+ else:
676
+ length = self.read_type(count_type, r, scope, fields, root, parent)
677
+ data = r.read_bytes(length)
678
+ return data.decode(encoding)
679
+
680
+ def _write_pstring(self, opts: dict, value: str, w: Writer, scope: Scope,
681
+ fields: dict, root, parent) -> None:
682
+ count_type = opts.get("countType", "varint")
683
+ encoding = opts.get("encoding", "utf-8")
684
+ data = value.encode(encoding)
685
+ if "count" not in opts:
686
+ self.write_type(count_type, len(data), w, scope, fields, root, parent)
687
+ w.write_bytes(data)
688
+
689
+ # ---- entityMetadataLoop (lista hasta encontrar terminador) -----------------
690
+
691
+ def _read_entity_metadata_loop(self, opts: dict, r: Reader, scope: Scope,
692
+ fields: dict, root, parent) -> list:
693
+ """
694
+ opts:
695
+ endVal: valor crudo (byte) que indica fin de la lista.
696
+ endType: tipo primitivo de 1 byte usado solo para el chequeo de
697
+ terminador (default 'u8'); NO se usa para decodificar
698
+ cada entrada real.
699
+ type: tipo completo de cada entrada (típicamente un container
700
+ que ya incluye, como campo anon, el bitfield real
701
+ type/key de Minecraft -- ver entityMetadata en el yml).
702
+
703
+ Bug real corregido acá: la versión anterior leía el primer byte de
704
+ cada entrada con `end_type` y lo usaba directo como "index" para
705
+ decidir el caso del switch -- pero ese byte es en realidad un
706
+ bitfield empaquetado `(type<<5)|key`, ya declarado como tal dentro
707
+ de `item_type` (el container en el yml). Al leerlo dos veces con
708
+ semánticas distintas (una vez acá como entero plano, descartado, y
709
+ de nuevo dentro del container como bitfield) el offset del reader
710
+ quedaba desalineado con el resto del paquete, produciendo
711
+ BufferUnderrun más adelante en la misma lectura.
712
+
713
+ La forma correcta (y genérica, sin hardcodear bits acá) es:
714
+ espiar el byte con peek_byte() SIN consumirlo para chequear si es
715
+ el terminador; si no lo es, dejar que `item_type` (el container
716
+ real, definido en el yml) lea la entrada completa -- incluyendo
717
+ ese mismo byte como parte de su propio bitfield.
718
+ """
719
+ end_val = opts.get("endVal", 0xFF)
720
+ item_type = opts["type"]
721
+
722
+ result = []
723
+ while True:
724
+ if r.peek_byte() == end_val:
725
+ r.read_bytes(1) # consumir el terminador
726
+ break
727
+ entry = self.read_type(item_type, r, scope, fields, root, parent)
728
+ result.append(entry)
729
+ return result
730
+
731
+ def _is_container_type(self, type_def: TypeDef, scope: Scope) -> bool:
732
+ """Resuelve (sin leer/escribir bytes) si un tipo es, en el fondo, un
733
+ container -- para saber si una entrada de entityMetadataLoop debe
734
+ pasarse como dict completo o desenvuelta en 'value'."""
735
+ seen: set[str] = set()
736
+ current = type_def
737
+ while isinstance(current, str):
738
+ if current in seen:
739
+ return False # ciclo raro, no asumimos container
740
+ seen.add(current)
741
+ if current in PRIMITIVES:
742
+ return False
743
+ resolved = self._resolve_named_type(current, scope)
744
+ if resolved is None:
745
+ return False
746
+ current = resolved
747
+ if isinstance(current, list) and len(current) == 2:
748
+ return current[0] == "container"
749
+ return False
750
+
751
+ def _write_entity_metadata_loop(self, opts: dict, value: list, w: Writer, scope: Scope,
752
+ fields: dict, root, parent) -> None:
753
+ """
754
+ Simétrico al fix de _read_entity_metadata_loop: cada `entry` en
755
+ `value` es el dict completo tal como lo devuelve la lectura
756
+ (típicamente {'type':.., 'key':.., 'value':..}, producido por el
757
+ container real declarado en `item_type` -- bitfield anon type/key
758
+ + campo nombrado value/switch). Se escribe con item_type
759
+ directamente, dejando que el container/bitfield/switch internos
760
+ empaqueten el byte type/key y el payload como corresponda. Al
761
+ final se escribe el byte terminador crudo (end_val) con end_type.
762
+ """
763
+ end_val = opts.get("endVal", 0xFF)
764
+ end_type = opts.get("endType", "u8")
765
+ item_type = opts["type"]
766
+
767
+ for entry in (value or []):
768
+ self.write_type(item_type, entry, w, scope, fields, root, parent)
769
+ self.write_type(end_type, end_val, w, scope, fields, root, parent)
770
+
771
+ # ---- topBitSetTerminatedArray ------------------------------------------------
772
+
773
+ def _read_top_bit_set_terminated_array(self, opts: dict, r: Reader, scope: Scope,
774
+ fields: dict, root, parent) -> list:
775
+ """
776
+ Lee entradas de `type` mientras el bit más alto (0x80) del PRIMER byte
777
+ de cada entrada esté seteado. Patrón típico de listas LEB128-like
778
+ en RakNet / formatos custom (cada entrada "anuncia" si hay otra después).
779
+ """
780
+ item_type = opts["type"]
781
+ result = []
782
+ while True:
783
+ start_offset = r.offset
784
+ item = self.read_type(item_type, r, scope, fields, root, parent)
785
+ result.append(item)
786
+ first_byte = r.buffer[start_offset]
787
+ if not (first_byte & 0x80):
788
+ break
789
+ return result
790
+
791
+ def _write_top_bit_set_terminated_array(self, opts: dict, value: list, w: Writer, scope: Scope,
792
+ fields: dict, root, parent) -> None:
793
+ """
794
+ Al escribir, el llamador es responsable de que cada item ya traiga el
795
+ bit alto seteado salvo el último (esto refleja el protocolo real: el
796
+ marcador de continuación suele ser parte de los datos del propio item,
797
+ no algo que este wrapper pueda inventar).
798
+ """
799
+ item_type = opts["type"]
800
+ items = value or []
801
+ for item in items:
802
+ self.write_type(item_type, item, w, scope, fields, root, parent)
803
+
804
+ # -------------------------------------------------------------------
805
+ # Escritura
806
+ # -------------------------------------------------------------------
807
+
808
+ def write_type(self, type_def: TypeDef, value: Any, w: Writer, scope: Scope | None,
809
+ fields: dict[str, Any], root: dict | None = None,
810
+ parent: dict | None = None) -> None:
811
+ if isinstance(type_def, str):
812
+ if type_def in PRIMITIVES:
813
+ PRIMITIVES[type_def].write(value, w)
814
+ return
815
+ resolved = self._resolve_named_type(type_def, scope)
816
+ if resolved is None:
817
+ raise UnknownTypeError(type_def)
818
+ return self.write_type(resolved, value, w, scope, fields, root, parent)
819
+
820
+ if isinstance(type_def, list) and len(type_def) == 2:
821
+ base, opts = type_def
822
+ handler = self._composite_write_handlers.get(base)
823
+ if handler is not None:
824
+ return handler(opts, value, w, scope, fields, root, parent)
825
+ named = self._resolve_named_type(base, scope)
826
+ if named is None:
827
+ raise UnknownTypeError(f"(tipo base compuesto) {base}")
828
+ substituted = substitute_type_args(named, opts if isinstance(opts, dict) else None)
829
+ return self.write_type(substituted, value, w, scope, fields, root, parent)
830
+
831
+ raise InvalidTypeDefinition(type_def)
832
+
833
+ # ---- cstring compuesto (["cstring", {"encoding": "..."}]) -----------------
834
+ # El primitivo "cstring" (primitives.py) sigue existiendo tal cual, fuerza
835
+ # utf-8 y no se toca. Esto es un tipo COMPUESTO adicional, solo se activa
836
+ # si el protocol.json usa la forma ["cstring", {opts}] en vez del string
837
+ # "cstring" a secas -- así que es 100% aditivo, no cambia comportamiento
838
+ # existente. Paridad con node-protodef (src/datatypes/utils.js: readCString/
839
+ # writeCString aceptan typeArgs.encoding, default 'utf8').
840
+
841
+ def _read_cstring_encoded(self, opts: dict, r: Reader, scope: Scope,
842
+ fields: dict, root, parent) -> str:
843
+ encoding = (opts or {}).get("encoding", "utf-8")
844
+ out = bytearray()
845
+ while True:
846
+ b = r.read_bytes(1)
847
+ if b == b"\x00":
848
+ break
849
+ out += b
850
+ return out.decode(encoding)
851
+
852
+ def _write_cstring_encoded(self, opts: dict, value: str, w: Writer, scope: Scope,
853
+ fields: dict, root, parent) -> None:
854
+ encoding = (opts or {}).get("encoding", "utf-8")
855
+ w.write_bytes(value.encode(encoding) + b"\x00")
856
+
857
+ # -------------------------------------------------------------------
858
+ # API de alto nivel: paquetes completos
859
+ # -------------------------------------------------------------------
860
+
861
+ def parse_packet(self, state: str, direction: str, data: bytes) -> ParsedPacket:
862
+ scope = self.get_scope(state, direction)
863
+ r = Reader(data)
864
+ packet = self.read_type("packet", r, scope, {})
865
+ return ParsedPacket(name=packet["name"], params=packet["params"], bytes_read=r.offset)
866
+
867
+ def serialize_packet(self, state: str, direction: str, name: str, params: dict) -> bytes:
868
+ scope = self.get_scope(state, direction)
869
+ w = Writer()
870
+ self.write_type("packet", {"name": name, "params": params}, w, scope, {})
871
+ return w.result()
872
+
873
+ # acceso directo a un tipo nombrado (sin pasar por "packet"), útil para tests
874
+ # y para parsear/serializar sub-estructuras sueltas (p.ej. un slot, un NBT)
875
+ def read_named(self, state: str, direction: str, type_name: str, data: bytes) -> Any:
876
+ scope = self.get_scope(state, direction)
877
+ r = Reader(data)
878
+ return self.read_type(type_name, r, scope, {})
879
+
880
+ def write_named(self, state: str, direction: str, type_name: str, value: Any) -> bytes:
881
+ scope = self.get_scope(state, direction)
882
+ w = Writer()
883
+ self.write_type(type_name, value, w, scope, {})
884
+ return w.result()