unidecompiler-plugin-lua 0.1.1__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.
@@ -0,0 +1,558 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import subprocess
5
+ import tempfile
6
+ from dataclasses import dataclass, replace
7
+ from pathlib import Path
8
+ import re
9
+ from typing import Protocol
10
+
11
+ from unidecompiler.plugins import FrontendDecodeError
12
+
13
+
14
+ LUA_SIGNATURE = b"\x1bLua"
15
+ LUA_51_VERSION = 0x51
16
+
17
+
18
+ class LuacDecodeError(FrontendDecodeError):
19
+ pass
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class LuacHeader:
24
+ version: int
25
+ format: int
26
+ little_endian: bool | None = None
27
+ int_size: int | None = None
28
+ size_t_size: int | None = None
29
+ instruction_size: int | None = None
30
+ lua_number_size: int | None = None
31
+ integral_numbers: bool | None = None
32
+
33
+ @property
34
+ def version_label(self) -> str:
35
+ major = self.version >> 4
36
+ minor = self.version & 0x0F
37
+ return f"{major}.{minor}"
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class LuaChunk:
42
+ header: LuacHeader
43
+ raw: bytes
44
+ filename: str | None = None
45
+ disassembly: str | None = None
46
+ functions: tuple["LuaFunctionListing", ...] = ()
47
+ decoder_id: str | None = None
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class LuaInstructionListing:
52
+ pc: int
53
+ line: int | None
54
+ opcode: str
55
+ operands: tuple[str, ...]
56
+ comment: str | None = None
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class LuaLocalListing:
61
+ slot: int
62
+ name: str
63
+ start_pc: int
64
+ end_pc: int
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class LuaUpvalueListing:
69
+ index: int
70
+ name: str | None = None
71
+ instack: int | None = None
72
+ slot: int | None = None
73
+ kind: int | None = None
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class LuaConstantListing:
78
+ index: int
79
+ kind: str
80
+ value: object
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class LuaFunctionListing:
85
+ kind: str
86
+ source: str
87
+ line_start: int | None
88
+ line_end: int | None
89
+ instruction_count: int
90
+ param_count: int
91
+ slot_count: int
92
+ upvalue_count: int
93
+ local_count: int
94
+ constant_count: int
95
+ child_function_count: int
96
+ instructions: tuple[LuaInstructionListing, ...]
97
+ constants: tuple[LuaConstantListing, ...]
98
+ locals: tuple[LuaLocalListing, ...]
99
+ upvalues: tuple[LuaUpvalueListing, ...] = ()
100
+ inferred_name: str | None = None
101
+ child_function_names: tuple[str, ...] = ()
102
+
103
+
104
+ class LuaChunkDecoder(Protocol):
105
+ """Adapter seam for Lua bytecode decoding.
106
+
107
+ Implementations may use third-party libraries, official Lua tools, or a
108
+ small internal fallback. The Lua frontend plugin depends on this seam rather
109
+ than on one concrete parser.
110
+ """
111
+
112
+ id: str
113
+
114
+ def can_decode(self, data: bytes, filename: str | None = None) -> bool:
115
+ ...
116
+
117
+ def decode(self, data: bytes, filename: str | None = None) -> LuaChunk:
118
+ ...
119
+
120
+
121
+ def looks_like_luac(data: bytes) -> bool:
122
+ return data.startswith(LUA_SIGNATURE)
123
+
124
+
125
+ def decode_lua51_header(data: bytes) -> LuacHeader:
126
+ """Decode a Lua 5.1 chunk header.
127
+
128
+ V1 intentionally pins Lua 5.1. Later frontends can add version-specific
129
+ decoders without changing the frontend plugin seam.
130
+ """
131
+
132
+ if len(data) < 12:
133
+ raise LuacDecodeError("truncated Lua chunk header")
134
+ if not looks_like_luac(data):
135
+ raise LuacDecodeError("missing Lua chunk signature")
136
+
137
+ version = data[4]
138
+ if version != LUA_51_VERSION:
139
+ raise LuacDecodeError(
140
+ f"unsupported Lua bytecode version 0x{version:02x}; V1 supports 0x51"
141
+ )
142
+
143
+ fmt = data[5]
144
+ if fmt != 0:
145
+ raise LuacDecodeError(f"unsupported Lua chunk format: {fmt}")
146
+
147
+ endianness = data[6]
148
+ if endianness not in (0, 1):
149
+ raise LuacDecodeError(f"invalid Lua 5.1 endianness flag: {endianness}")
150
+
151
+ return LuacHeader(
152
+ version=version,
153
+ format=fmt,
154
+ little_endian=endianness == 1,
155
+ int_size=data[7],
156
+ size_t_size=data[8],
157
+ instruction_size=data[9],
158
+ lua_number_size=data[10],
159
+ integral_numbers=data[11] == 1,
160
+ )
161
+
162
+
163
+ def decode_lua_header(data: bytes) -> LuacHeader:
164
+ if len(data) < 6:
165
+ raise LuacDecodeError("truncated Lua chunk header")
166
+ if not looks_like_luac(data):
167
+ raise LuacDecodeError("missing Lua chunk signature")
168
+
169
+ if data[4] == LUA_51_VERSION:
170
+ return decode_lua51_header(data)
171
+
172
+ return LuacHeader(version=data[4], format=data[5])
173
+
174
+
175
+ def decode_lua51_chunk(data: bytes, filename: str | None = None) -> LuaChunk:
176
+ return LuaChunk(header=decode_lua51_header(data), raw=data, filename=filename)
177
+
178
+
179
+ FUNCTION_HEADER_RE = re.compile(
180
+ r"^(main|function) <(?P<source>.*):(?P<start>\d+),(?P<end>\d+)> "
181
+ r"\((?P<instructions>\d+) instructions at .*\)$"
182
+ )
183
+ FUNCTION_STATS_RE = re.compile(
184
+ r"^(?P<params>\d+)\+? params?, (?P<slots>\d+) slots, "
185
+ r"(?P<upvalues>\d+) upvalues?, (?P<locals>\d+) locals?, "
186
+ r"(?P<constants>\d+) constants?, (?P<functions>\d+) functions?$"
187
+ )
188
+ INSTRUCTION_RE = re.compile(
189
+ r"^\s*(?P<pc>\d+)\s+\[(?P<line>-|\d+)\]\s+"
190
+ r"(?P<opcode>[A-Z0-9_]+)\s*(?P<rest>.*)$"
191
+ )
192
+ LOCAL_RE = re.compile(
193
+ r"^\s*(?P<slot>\d+)\s+(?P<name>\S+)\s+"
194
+ r"(?P<start>\d+)\s+(?P<end>\d+)\s*$"
195
+ )
196
+ CONSTANT_RE = re.compile(
197
+ r"^\s*(?P<index>\d+)\s+(?P<kind>\S+)\s*(?P<value>.*)$"
198
+ )
199
+
200
+
201
+ def parse_luac_listing(disassembly: str) -> tuple[LuaFunctionListing, ...]:
202
+ """Parse the stable parts of ``luac -l -l`` output.
203
+
204
+ The official listing format is not a formal interchange format, so this
205
+ parser intentionally extracts only the pieces the first lifter needs:
206
+ function headers, instructions, and local-variable tables.
207
+ """
208
+
209
+ lines = disassembly.splitlines()
210
+ functions: list[LuaFunctionListing] = []
211
+ index = 0
212
+
213
+ while index < len(lines):
214
+ header_match = FUNCTION_HEADER_RE.match(lines[index].strip())
215
+ if header_match is None:
216
+ index += 1
217
+ continue
218
+
219
+ kind = lines[index].strip().split(" ", 1)[0]
220
+ index += 1
221
+ if index >= len(lines):
222
+ break
223
+
224
+ stats_match = FUNCTION_STATS_RE.match(lines[index].strip())
225
+ if stats_match is None:
226
+ index += 1
227
+ continue
228
+
229
+ index += 1
230
+ instructions: list[LuaInstructionListing] = []
231
+ constants: list[LuaConstantListing] = []
232
+ locals_: list[LuaLocalListing] = []
233
+ upvalues: list[LuaUpvalueListing] = []
234
+
235
+ while index < len(lines):
236
+ line = lines[index]
237
+ if FUNCTION_HEADER_RE.match(line.strip()):
238
+ break
239
+
240
+ instruction_match = INSTRUCTION_RE.match(line)
241
+ if instruction_match is not None:
242
+ rest = instruction_match.group("rest").strip()
243
+ operands_text, _, comment_text = rest.partition(";")
244
+ instructions.append(
245
+ LuaInstructionListing(
246
+ pc=int(instruction_match.group("pc")),
247
+ line=(
248
+ None
249
+ if instruction_match.group("line") == "-"
250
+ else int(instruction_match.group("line"))
251
+ ),
252
+ opcode=instruction_match.group("opcode"),
253
+ operands=tuple(operands_text.split()),
254
+ comment=comment_text.strip() or None,
255
+ )
256
+ )
257
+ index += 1
258
+ continue
259
+
260
+ if line.strip().startswith("locals "):
261
+ index += 1
262
+ while index < len(lines):
263
+ local_match = LOCAL_RE.match(lines[index])
264
+ if local_match is None:
265
+ break
266
+ locals_.append(
267
+ LuaLocalListing(
268
+ slot=int(local_match.group("slot")),
269
+ name=local_match.group("name"),
270
+ start_pc=int(local_match.group("start")),
271
+ end_pc=int(local_match.group("end")),
272
+ )
273
+ )
274
+ index += 1
275
+ continue
276
+
277
+ if line.strip().startswith("constants "):
278
+ index += 1
279
+ while index < len(lines):
280
+ constant_match = CONSTANT_RE.match(lines[index])
281
+ if constant_match is None:
282
+ break
283
+ constants.append(
284
+ LuaConstantListing(
285
+ index=int(constant_match.group("index")),
286
+ kind=constant_match.group("kind"),
287
+ value=_parse_lua_constant(
288
+ constant_match.group("kind"),
289
+ constant_match.group("value").strip(),
290
+ ),
291
+ )
292
+ )
293
+ index += 1
294
+ continue
295
+
296
+ if line.strip().startswith("upvalues "):
297
+ index += 1
298
+ upvalue_index = 0
299
+ while index < len(lines):
300
+ parts = lines[index].split()
301
+ if len(parts) < 4 or not parts[0].isdigit():
302
+ break
303
+ upvalues.append(
304
+ LuaUpvalueListing(
305
+ index=int(parts[0]),
306
+ name=parts[1],
307
+ instack=_parse_int(parts[2]),
308
+ slot=_parse_int(parts[3]),
309
+ )
310
+ )
311
+ upvalue_index += 1
312
+ index += 1
313
+ continue
314
+
315
+ index += 1
316
+
317
+ functions.append(
318
+ LuaFunctionListing(
319
+ kind=kind,
320
+ source=header_match.group("source"),
321
+ line_start=int(header_match.group("start")),
322
+ line_end=int(header_match.group("end")),
323
+ instruction_count=int(header_match.group("instructions")),
324
+ param_count=int(stats_match.group("params")),
325
+ slot_count=int(stats_match.group("slots")),
326
+ upvalue_count=int(stats_match.group("upvalues")),
327
+ local_count=int(stats_match.group("locals")),
328
+ constant_count=int(stats_match.group("constants")),
329
+ child_function_count=int(stats_match.group("functions")),
330
+ instructions=tuple(instructions),
331
+ constants=tuple(constants),
332
+ locals=tuple(locals_),
333
+ upvalues=tuple(upvalues),
334
+ )
335
+ )
336
+
337
+ return _infer_function_names(tuple(functions))
338
+
339
+
340
+ def _infer_function_names(
341
+ functions: tuple[LuaFunctionListing, ...],
342
+ ) -> tuple[LuaFunctionListing, ...]:
343
+ if not functions:
344
+ return functions
345
+
346
+ names: list[str | None] = [None for _ in functions]
347
+ child_function_names: list[list[str]] = [[] for _ in functions]
348
+ names[0] = "<chunk>"
349
+ child_index = 1
350
+
351
+ for parent_index, parent in enumerate(functions):
352
+ for instruction in parent.instructions:
353
+ if instruction.opcode != "CLOSURE" or len(instruction.operands) < 1:
354
+ continue
355
+ if child_index >= len(functions):
356
+ break
357
+ register = _parse_int(instruction.operands[0])
358
+ if register is None:
359
+ child_index += 1
360
+ continue
361
+ local_name = _local_name_for_register(
362
+ parent.locals,
363
+ register=register,
364
+ pc=instruction.pc + 1,
365
+ )
366
+ if local_name is not None:
367
+ names[child_index] = local_name
368
+ child_function_names[parent_index].append(names[child_index] or f"<function_{child_index}>")
369
+ child_index += 1
370
+
371
+ return tuple(
372
+ LuaFunctionListing(
373
+ kind=function.kind,
374
+ source=function.source,
375
+ line_start=function.line_start,
376
+ line_end=function.line_end,
377
+ instruction_count=function.instruction_count,
378
+ param_count=function.param_count,
379
+ slot_count=function.slot_count,
380
+ upvalue_count=function.upvalue_count,
381
+ local_count=function.local_count,
382
+ constant_count=function.constant_count,
383
+ child_function_count=function.child_function_count,
384
+ instructions=function.instructions,
385
+ constants=function.constants,
386
+ locals=function.locals,
387
+ upvalues=function.upvalues,
388
+ inferred_name=names[index] or f"<function_{index}>",
389
+ child_function_names=tuple(child_function_names[index]),
390
+ )
391
+ for index, function in enumerate(functions)
392
+ )
393
+
394
+
395
+ def _local_name_for_register(
396
+ locals_: tuple[LuaLocalListing, ...],
397
+ register: int,
398
+ pc: int,
399
+ ) -> str | None:
400
+ for local in locals_:
401
+ if local.slot == register and local.start_pc <= pc <= local.end_pc:
402
+ return local.name
403
+ return None
404
+
405
+
406
+ def _parse_int(value: str) -> int | None:
407
+ try:
408
+ return int(value)
409
+ except ValueError:
410
+ return None
411
+
412
+
413
+ def _parse_lua_constant(kind: str, value: str) -> object:
414
+ if kind == "I":
415
+ return int(value)
416
+ if kind == "F":
417
+ return float(value)
418
+ if kind == "S":
419
+ if len(value) >= 2 and value[0] == '"' and value[-1] == '"':
420
+ return value[1:-1]
421
+ return value
422
+ if kind == "b":
423
+ return value.lower() == "true"
424
+ if kind == "N":
425
+ return None
426
+ return value
427
+
428
+
429
+ class HeaderOnlyLuaChunkDecoder:
430
+ id = "lua-header-only"
431
+
432
+ def can_decode(self, data: bytes, filename: str | None = None) -> bool:
433
+ return looks_like_luac(data)
434
+
435
+ def decode(self, data: bytes, filename: str | None = None) -> LuaChunk:
436
+ return LuaChunk(
437
+ header=decode_lua_header(data),
438
+ raw=data,
439
+ filename=filename,
440
+ decoder_id=self.id,
441
+ )
442
+
443
+
444
+ class LuacToolChunkDecoder:
445
+ """Decode/disassemble chunks through the official ``luac`` executable."""
446
+
447
+ id = "luac-tool"
448
+
449
+ def __init__(self, luac_path: str = "luac") -> None:
450
+ self.luac_path = luac_path
451
+
452
+ def can_decode(self, data: bytes, filename: str | None = None) -> bool:
453
+ return looks_like_luac(data) and shutil.which(self.luac_path) is not None
454
+
455
+ def decode(self, data: bytes, filename: str | None = None) -> LuaChunk:
456
+ header = decode_lua_header(data)
457
+ luac = shutil.which(self.luac_path)
458
+ if luac is None:
459
+ raise LuacDecodeError(f"luac executable not found: {self.luac_path}")
460
+
461
+ with tempfile.TemporaryDirectory(prefix="unidecompiler-luac-") as temp_dir:
462
+ chunk_path = Path(temp_dir) / "input.luac"
463
+ chunk_path.write_bytes(data)
464
+ result = subprocess.run(
465
+ [luac, "-l", "-l", chunk_path.name],
466
+ check=False,
467
+ capture_output=True,
468
+ text=True,
469
+ cwd=temp_dir,
470
+ )
471
+
472
+ if result.returncode != 0:
473
+ detail = result.stderr.strip() or result.stdout.strip()
474
+ raise LuacDecodeError(f"luac failed to decode chunk: {detail}")
475
+
476
+ return LuaChunk(
477
+ header=header,
478
+ raw=data,
479
+ filename=filename,
480
+ disassembly=result.stdout,
481
+ functions=parse_luac_listing(result.stdout),
482
+ decoder_id=self.id,
483
+ )
484
+
485
+
486
+ class LuaBytecodeLibraryChunkDecoder:
487
+ """Adapter seam for importable Lua bytecode/chunk libraries.
488
+
489
+ This intentionally does not implement a binary chunk parser itself. The
490
+ project policy is that bytecode container parsing belongs to a maintained
491
+ frontend dependency/adapter, not to the generic pipeline. At the time this
492
+ adapter was added, no reliable Python package exposing Lua 5.4 chunk
493
+ functions/instructions/constants/locals was available in the environment,
494
+ so the adapter reports unavailable instead of pretending that source-level
495
+ parsers can decode luac bytecode.
496
+ """
497
+
498
+ id = "lua-bytecode-library-unavailable"
499
+
500
+ def can_decode(self, data: bytes, filename: str | None = None) -> bool:
501
+ return looks_like_luac(data) and self._available_backend_id() is not None
502
+
503
+ def decode(self, data: bytes, filename: str | None = None) -> LuaChunk:
504
+ backend_id = self._available_backend_id()
505
+ if backend_id is None:
506
+ raise LuacDecodeError(
507
+ "no supported importable Lua 5.4 bytecode parser is available"
508
+ )
509
+
510
+ raise LuacDecodeError(
511
+ f"Lua bytecode library backend {backend_id!r} is not wired to the "
512
+ "LuaFunctionListing adapter yet"
513
+ )
514
+
515
+ def _available_backend_id(self) -> str | None:
516
+ return None
517
+
518
+
519
+ class PreferredLuaChunkDecoder:
520
+ """Try importable library decoders before safe internal fallbacks.
521
+
522
+ The default path must not shell out to ``luac``. ``LuacToolChunkDecoder`` is
523
+ intentionally excluded here and may only be used by explicitly injecting it
524
+ into ``LuaFrontendPlugin`` or this preferred decoder.
525
+ """
526
+
527
+ id = "lua-library-preferred"
528
+
529
+ def __init__(self, decoders: tuple[LuaChunkDecoder, ...] | None = None) -> None:
530
+ if decoders is None:
531
+ from unidecompiler_plugin_lua.plugin import Lua54BinaryChunkDecoder
532
+
533
+ decoders = (
534
+ Lua54BinaryChunkDecoder(),
535
+ LuaBytecodeLibraryChunkDecoder(),
536
+ HeaderOnlyLuaChunkDecoder(),
537
+ )
538
+ self.decoders = decoders
539
+
540
+ def can_decode(self, data: bytes, filename: str | None = None) -> bool:
541
+ return any(decoder.can_decode(data, filename) for decoder in self.decoders)
542
+
543
+ def decode(self, data: bytes, filename: str | None = None) -> LuaChunk:
544
+ errors: list[str] = []
545
+ for decoder in self.decoders:
546
+ if not decoder.can_decode(data, filename):
547
+ continue
548
+ try:
549
+ chunk = decoder.decode(data, filename)
550
+ if chunk.decoder_id is None:
551
+ return replace(chunk, decoder_id=decoder.id)
552
+ return chunk
553
+ except LuacDecodeError as error:
554
+ errors.append(f"{decoder.id}: {error}")
555
+
556
+ if errors:
557
+ raise LuacDecodeError("; ".join(errors))
558
+ raise LuacDecodeError("no Lua decoder can decode this input")