tfbscript 0.1.0__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.
Files changed (72) hide show
  1. tfbscript/__init__.py +21 -0
  2. tfbscript/__main__.py +52 -0
  3. tfbscript/ansi.py +203 -0
  4. tfbscript/arena.py +119 -0
  5. tfbscript/binary.py +84 -0
  6. tfbscript/debug.py +14 -0
  7. tfbscript/editor/__init__.py +3 -0
  8. tfbscript/editor/app.py +141 -0
  9. tfbscript/editor/fonts/ms-sans-serif/MS Sans Serif.ttf +0 -0
  10. tfbscript/editor/fonts/ms-sans-serif/license.txt +4 -0
  11. tfbscript/editor/fonts/ms-sans-serif/readme.txt +26 -0
  12. tfbscript/editor/fonts/ms-sans-serif-bold/MS Sans Serif Bold.ttf +0 -0
  13. tfbscript/editor/fonts/ms-sans-serif-bold/license.txt +4 -0
  14. tfbscript/editor/fonts/ms-sans-serif-bold/readme.txt +26 -0
  15. tfbscript/editor/fonts.py +98 -0
  16. tfbscript/editor/search.py +200 -0
  17. tfbscript/editor/style.py +141 -0
  18. tfbscript/editor/widgets.py +321 -0
  19. tfbscript/opcodes/__init__.py +104 -0
  20. tfbscript/opcodes/base.py +362 -0
  21. tfbscript/opcodes/block.py +34 -0
  22. tfbscript/opcodes/enums.py +219 -0
  23. tfbscript/opcodes/op_behavior_implementation.py +39 -0
  24. tfbscript/opcodes/op_change_membership.py +62 -0
  25. tfbscript/opcodes/op_check_fov.py +73 -0
  26. tfbscript/opcodes/op_check_membership.py +41 -0
  27. tfbscript/opcodes/op_check_message.py +79 -0
  28. tfbscript/opcodes/op_check_reference.py +61 -0
  29. tfbscript/opcodes/op_check_value.py +66 -0
  30. tfbscript/opcodes/op_comment.py +43 -0
  31. tfbscript/opcodes/op_control.py +80 -0
  32. tfbscript/opcodes/op_create_variable.py +42 -0
  33. tfbscript/opcodes/op_cut_scene.py +44 -0
  34. tfbscript/opcodes/op_dec_value.py +43 -0
  35. tfbscript/opcodes/op_displace.py +111 -0
  36. tfbscript/opcodes/op_find_subset.py +49 -0
  37. tfbscript/opcodes/op_find_variable.py +28 -0
  38. tfbscript/opcodes/op_for_each.py +65 -0
  39. tfbscript/opcodes/op_if_else.py +105 -0
  40. tfbscript/opcodes/op_inc_value.py +43 -0
  41. tfbscript/opcodes/op_loop_value.py +28 -0
  42. tfbscript/opcodes/op_move_from.py +47 -0
  43. tfbscript/opcodes/op_move_to.py +99 -0
  44. tfbscript/opcodes/op_play_animation.py +46 -0
  45. tfbscript/opcodes/op_play_sound.py +42 -0
  46. tfbscript/opcodes/op_prescript.py +26 -0
  47. tfbscript/opcodes/op_print.py +53 -0
  48. tfbscript/opcodes/op_remove.py +43 -0
  49. tfbscript/opcodes/op_reset.py +42 -0
  50. tfbscript/opcodes/op_run_as_player.py +26 -0
  51. tfbscript/opcodes/op_send_message.py +53 -0
  52. tfbscript/opcodes/op_set_behavior.py +41 -0
  53. tfbscript/opcodes/op_set_reference.py +52 -0
  54. tfbscript/opcodes/op_set_value.py +52 -0
  55. tfbscript/opcodes/op_shutdown.py +26 -0
  56. tfbscript/opcodes/op_slide_value.py +81 -0
  57. tfbscript/opcodes/op_spawn_actor.py +96 -0
  58. tfbscript/opcodes/op_startup.py +26 -0
  59. tfbscript/opcodes/op_stop_sound.py +26 -0
  60. tfbscript/opcodes/op_teleport_to.py +47 -0
  61. tfbscript/opcodes/op_turn_to.py +33 -0
  62. tfbscript/opcodes/op_use_camera.py +68 -0
  63. tfbscript/payload.py +40 -0
  64. tfbscript/reference.py +926 -0
  65. tfbscript/rhs.py +326 -0
  66. tfbscript/script.py +160 -0
  67. tfbscript/string_table.py +101 -0
  68. tfbscript-0.1.0.dist-info/METADATA +116 -0
  69. tfbscript-0.1.0.dist-info/RECORD +72 -0
  70. tfbscript-0.1.0.dist-info/WHEEL +4 -0
  71. tfbscript-0.1.0.dist-info/entry_points.txt +2 -0
  72. tfbscript-0.1.0.dist-info/licenses/LICENSE +201 -0
tfbscript/rhs.py ADDED
@@ -0,0 +1,326 @@
1
+ """The 5-11 byte right-hand-side operand used by value opcodes."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import IntEnum
5
+ from typing import TYPE_CHECKING, BinaryIO, cast, override
6
+
7
+ from tfbscript.ansi import number, operator, rgb_square, type_
8
+ from tfbscript.binary import write_f32, write_i16, write_rgba, write_s32, write_u8
9
+ from tfbscript.reference import Reference
10
+ from tfbscript.string_table import StringTable
11
+
12
+ if TYPE_CHECKING:
13
+ from tfbscript.binary import BinaryReader
14
+ from tfbscript.opcodes.base import ParserContext
15
+
16
+ _OPERATORS = {
17
+ 0x00: "+",
18
+ 0x01: "-",
19
+ 0x02: "*",
20
+ 0x03: "/",
21
+ }
22
+
23
+ class RhsKind(IntEnum):
24
+ ADWAD = 234
25
+ @dataclass
26
+ class Rhs:
27
+ tag: int = 0x00
28
+ kind: str = (
29
+ "null" # 'int' | 'float' | 'color' | 'pair' | 'reference' | 'expression'
30
+ )
31
+ value: object = None
32
+ operator: int | None = None
33
+ rhs: "Rhs | None" = None
34
+
35
+ @property
36
+ def is_random(self) -> bool:
37
+ """Whether this immediate is a random ceiling (low-nibble bit 0).
38
+
39
+ Only meaningful for int/float kinds: tags 0x01 / 0x11 mean the engine
40
+ produces a uniform random value in [0, value) at runtime instead of
41
+ using the literal verbatim.
42
+ """
43
+ return self.kind in ("int", "float") and bool(self.tag & 0x01)
44
+
45
+ @classmethod
46
+ def read(
47
+ cls,
48
+ reader: "BinaryReader",
49
+ global_refs: StringTable,
50
+ local_refs: StringTable,
51
+ context: "ParserContext | None" = None,
52
+ ) -> "Rhs":
53
+ """Parse a 5-11 byte TFB-Script RHS.
54
+
55
+ Args:
56
+ reader: BinaryReader positioned at the RHS.
57
+ global_refs: the script's 2nd string table (globals) for name resolution.
58
+ local_refs: the script's 3rd string table (locals) for name resolution.
59
+ context: the script's parser context, threaded through to any
60
+ embedded References so they can resolve builtin types.
61
+ Returns:
62
+ An Rhs.
63
+ """
64
+
65
+ # A reference is 5 bytes (tag + 4-byte ref) unless there's room left for
66
+ # the 6-byte "op + value2" tail (11 bytes total), decided here from how
67
+ # many bytes are left in the payload.
68
+ #
69
+ # This is a proxy, not what the engine does. The real reader is
70
+ # TFBScript::readRHS @ 0043fd20 (4 stack args, RET 0x10; arg1 = the
71
+ # record, arg2 = a "gate" object, arg3 = a flag, arg4 unused). Its only
72
+ # tail branch is:
73
+ #
74
+ # 0043fd59 CMP byte [EDI],2 ; tag A, from the stream
75
+ # 0043fd63 JNZ 0043fe3e ; constant -> no tail
76
+ # 0043fd69 TEST EBP,EBP ; EBP = arg2, the gate
77
+ # 0043fd6b JZ 0043fd82 ; gate NULL -> tail
78
+ # 0043fd6d CALL 0042fdb0 ; the `value` exemplar
79
+ # 0043fd75 CALL 0043bab0 ; is-a(gate, value); `set` counts as value
80
+ # 0043fd7c JZ 0043fe3e ; not is-a value -> NO tail (5 bytes)
81
+ # 0043fd82 ... ; tail: op + tag B + term B (11 bytes)
82
+ #
83
+ # Call sites pass as the gate either DAT_0061f870 -- the descriptor
84
+ # FUN_004346f0 left from the last reference read in the same parse
85
+ # (set value 0042fb82, check value, check message 0043aecf, send
86
+ # message 0043af7f) -- or a fixed exemplar (`angle` at 00431457 /
87
+ # 00435a3f / 00435a56, `value` at 00435a99) or NULL (00430284).
88
+ #
89
+ # NOT YET REPRODUCIBLE STATICALLY. The outcome *is* static: keyed by
90
+ # (script, LHS operand word) there are 0 conflicts in 5165 pairs. But
91
+ # it is not a function of the gate's type as modelled here -- restricted
92
+ # to set value, whose gate is provably the LHS, the same actor field
93
+ # descriptor yields both sizes across files (waypoints 555/75,
94
+ # heading (OBSOLETE) 9/61, origin 11/5, tint color 6/5). Field-class
95
+ # identity does not separate them either: the 56 ::actor field
96
+ # descriptors (array 0x0061fdc0, builder FUN_00436f80) fall into just 6
97
+ # getClass groups -- 42fdb0 `value` (42 fields), 43d2d0 `set`
98
+ # (waypoints/attach points/clones/turrets), 4302c0 `angle`
99
+ # (heading/facing/cone angle/cone sweep offset), 42fdf0 colour
100
+ # (blip/cone/tint color), 430300 point (origin/destination), 42fe70
101
+ # (current speed) -- and every one of those groups shows both sizes.
102
+ # So DAT_0061f870 at the call must not always be the last-read
103
+ # reference; that is the open thread.
104
+ #
105
+ # Meanwhile the length proxy holds: the authoring tool sized
106
+ # payload_size to whatever it emitted, so the remaining-bytes boundary
107
+ # reproduces the decision. Verified across the corpus (0 parse errors,
108
+ # and every round-trip mismatch attributable to opcodes with no
109
+ # write_payload).
110
+
111
+ available = reader.size_remaining()
112
+ tag = reader.read_u8()
113
+
114
+ # Reference, optionally extended to an expression by an operator tail.
115
+ if tag == 0x02:
116
+ ref = Reference.read(reader, global_refs, local_refs, context)
117
+
118
+ if available < 11 or not cls._tail_follows(reader):
119
+ return cls(tag, "reference", ref)
120
+
121
+ op = reader.read_u8()
122
+ # The tail's second operand is a plain TERM, never another
123
+ # expression: Game.exe's readRHS reads it with a single
124
+ # FUN_004346f0 (reference, 0043fe27) or one constant reader, and
125
+ # there is no operator after it. Recursing into Rhs.read here would
126
+ # eat the following field whenever term B is itself a reference --
127
+ # e.g. slide value `.. 02 refA 00 02 refB | 10 cd cc cc 3e ..`,
128
+ # where the 0x10 float tag gets misread as an operator and 0xCD (the
129
+ # mantissa of 0.4f) as a tag.
130
+ rhs = cls._read_term(reader, global_refs, local_refs, context)
131
+ return cls(tag, "expression", ref, operator=op, rhs=rhs)
132
+
133
+ return cls._read_term_body(reader, tag, global_refs, local_refs, context)
134
+
135
+ @staticmethod
136
+ def _tail_follows(reader: "BinaryReader") -> bool:
137
+ """Whether an operator + second term really follows the reference just read.
138
+
139
+ The remaining-bytes test alone is not enough: an opcode that has more
140
+ fields after its RHS leaves plenty of room even when there is no tail,
141
+ and the reader then eats the next field. Game.exe decides this by type
142
+ (readRHS @ 0043fd20 takes the tail only when its gate argument is-a
143
+ `value`), which a static decoder cannot reproduce -- but the bytes a
144
+ real tail must have are tightly constrained, and that is checkable:
145
+
146
+ * the operator at rec+0xb is a 2-bit selector, 0 `+` / 1 `-` / 2 `*` /
147
+ 3 `/`. The evaluator (FUN_004400C0 @ 0044022C and 004402FE) does
148
+ `CMP EAX,3; JA` and falls through doing nothing above 3, so a byte
149
+ > 3 is not an operator;
150
+ * the second term's tag is one the reader dispatches on -- exactly
151
+ 0x02, or a high nibble of 0x00/0x10/0x20/0x30/0x80.
152
+
153
+ Measured over the corpus: 40,543 tails, operator 0-3 in every genuine
154
+ one; the only 7 exceptions had 0x10 there -- a float tag from the
155
+ *following* field, i.e. precisely the over-read this rejects.
156
+ """
157
+ if reader.size_remaining() < 6:
158
+ return False
159
+
160
+ op = reader.data[reader.offset]
161
+ tag_b = reader.data[reader.offset + 1]
162
+
163
+ if op > 0x03:
164
+ return False
165
+ if tag_b == 0x02:
166
+ return True
167
+ return (tag_b & 0xF0) in (0x00, 0x10, 0x20, 0x30, 0x80)
168
+
169
+ @classmethod
170
+ def _read_term(
171
+ cls,
172
+ reader: "BinaryReader",
173
+ global_refs: StringTable,
174
+ local_refs: StringTable,
175
+ context: "ParserContext | None" = None,
176
+ ) -> "Rhs":
177
+ """Read one operand of an expression: a tag plus its value, with no
178
+ operator tail. Used for the second operand only."""
179
+ tag = reader.read_u8()
180
+
181
+ if tag == 0x02:
182
+ ref = Reference.read(reader, global_refs, local_refs, context)
183
+ return cls(tag, "reference", ref)
184
+
185
+ return cls._read_term_body(reader, tag, global_refs, local_refs, context)
186
+
187
+ @classmethod
188
+ def _read_term_body(
189
+ cls,
190
+ reader: "BinaryReader",
191
+ tag: int,
192
+ global_refs: StringTable,
193
+ local_refs: StringTable,
194
+ context: "ParserContext | None" = None,
195
+ ) -> "Rhs":
196
+ """Read the value bytes of a non-reference term, given its tag."""
197
+
198
+ if tag & 0x01 not in (0x00, 0x01):
199
+ raise ValueError(f"Unknown RHS tag 0x{tag:02X} (low nibble {tag & 0x0F} <- CAUSE)")
200
+
201
+ # The high nibble selects the kind (int/float/color/pair); the low
202
+ # nibble is a "randomize" modifier -- bit 0 only. Confirmed from the
203
+ # runtime operand evaluators (Game.exe FUN_004400c0 / FUN_00440570),
204
+ # which switch on the exact tag byte:
205
+ # 0x00 int literal 0x01 int, random ceiling
206
+ # 0x10 float literal 0x11 float, random ceiling
207
+ # For a random tag the engine yields a uniform value in [0, value) at
208
+ # runtime (`rand() * value / 0x8000`). We keep the whole byte in `tag`,
209
+ # so the modifier round-trips; see the `is_random` property below.
210
+
211
+ # Integer
212
+ if (tag & 0xF0) == 0x00:
213
+ return cls(tag, "int", reader.read_i32())
214
+
215
+ # Float (0x80 is the engine's "indirect float", normalized to 0x10 by
216
+ # the reader; it takes the same 4-byte float payload).
217
+ if (tag & 0xF0) in (0x10, 0x80):
218
+ value = round(reader.read_f32(), 7) # round for display
219
+ return cls(tag, "float", value)
220
+
221
+ # RGBA color
222
+ if (tag & 0xF0) == 0x20:
223
+ return cls(tag, "color", reader.read_rgba())
224
+
225
+ # int16 pair
226
+ if (tag & 0xF0) == 0x30:
227
+ return cls(tag, "pair", (reader.read_i16(), reader.read_i16()))
228
+
229
+ raise ValueError(f"Unknown RHS tag 0x{tag:02X}")
230
+
231
+ def write(self, f: BinaryIO) -> None:
232
+ write_u8(f, self.tag)
233
+
234
+ if self.kind in ("reference", "expression"):
235
+ cast(Reference, self.value).write(f)
236
+ if self.kind == "expression":
237
+ assert self.operator is not None
238
+ write_u8(f, self.operator)
239
+ assert self.rhs is not None
240
+ self.rhs.write(f)
241
+ return
242
+
243
+ if self.kind == "int":
244
+ write_s32(f, cast(int, self.value))
245
+ return
246
+
247
+ if self.kind == "float":
248
+ write_f32(f, cast(float, self.value))
249
+ return
250
+
251
+ if self.kind == "color":
252
+ write_rgba(f, cast(tuple[int, int, int, int], self.value))
253
+ return
254
+
255
+ if self.kind == "pair":
256
+ x, y = cast(tuple[int, int], self.value)
257
+ write_i16(f, x)
258
+ write_i16(f, y)
259
+ return
260
+
261
+ raise ValueError(f"Unknown RHS kind {self.kind}")
262
+
263
+ def to_string(
264
+ self,
265
+ show_types: bool = False,
266
+ do_reconstructs: bool = False,
267
+ ) -> str:
268
+ """Render this RHS as a readable, syntax-colored string.
269
+ do_reconstructs: If True, reconstructs the compiler's "x + 0" / "x - 0" expression to just "x"
270
+ """
271
+
272
+ if self.kind == "int":
273
+ prefix = type_("Int: ") if show_types else ""
274
+ body = number(self.value)
275
+ # 0x01: random ceiling -> runtime value is uniform in [0, value).
276
+ return prefix + (f"random(0, {body})" if self.is_random else body)
277
+
278
+ if self.kind == "float":
279
+ prefix = type_("Float: ") if show_types else ""
280
+ body = number(self.value)
281
+ # 0x11: random ceiling -> runtime value is uniform in [0, value).
282
+ return prefix + (f"random(0, {body})" if self.is_random else body)
283
+
284
+ if self.kind == "color":
285
+ r, g, b, a = cast(tuple[int, int, int, int], self.value)
286
+ prefix = type_("RGBA: ") if show_types else type_("Color")
287
+ return rgb_square(r, g, b) + prefix + number(f"({r}, {g}, {b}, {a})")
288
+
289
+ if self.kind == "pair":
290
+ x, y = cast(tuple[int, int], self.value)
291
+ prefix = type_("Pair16: ") if show_types else type_("Pair")
292
+ return prefix + number(f"({x}, {y})")
293
+
294
+ if self.kind == "reference":
295
+ return f"Ref: {self.value}" if show_types else f"{self.value}"
296
+
297
+ if self.kind == "expression":
298
+ if self.operator is not None:
299
+ op = _OPERATORS.get(self.operator, f"unknown_operator_{self.operator}")
300
+ op = operator(op)
301
+ if "unknown_operator_" in op: # TODO: bad way of doing it
302
+ print("WARN: UNKNOWN OPERATOR - " + hex(self.operator))
303
+ raise ValueError("WARN: UNKNOWN OPERATOR - " + hex(self.operator))
304
+ else:
305
+ op = "unknown_operator_None"
306
+
307
+ left = f"Ref: {self.value}" if show_types else f"{self.value}"
308
+ right = self.rhs.to_string(show_types) if self.rhs else "unknown_rhs_None"
309
+
310
+ # `x + 0` / `x - 0` is the compiler's way of storing a bare
311
+ # reference; reconstruct it back to just `x`.
312
+ is_plus_minus_zero = (
313
+ self.rhs is not None
314
+ and self.rhs.kind == "int"
315
+ and self.rhs.value == 0
316
+ and self.operator in (0, 1)
317
+ )
318
+ if do_reconstructs and is_plus_minus_zero:
319
+ return left
320
+ return f"({left} {op} {right})"
321
+
322
+ return f"Unknown({self.kind}): {self.value}"
323
+
324
+ @override
325
+ def __str__(self) -> str:
326
+ return self.to_string()
tfbscript/script.py ADDED
@@ -0,0 +1,160 @@
1
+ """The top-level TFB script (.ai) file."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import BinaryIO
6
+
7
+ from tfbscript import ansi
8
+ from tfbscript.binary import BinaryReader, write_u32, write_u8
9
+ from tfbscript.arena import arena_dwords
10
+ from tfbscript.debug import DebugStore
11
+ from tfbscript.opcodes import Opcode, ParserContext
12
+ from tfbscript.string_table import StringTable
13
+
14
+
15
+ @dataclass
16
+ class ScriptFile:
17
+ """A parsed TFB script (.ai) file."""
18
+
19
+ _file_path: Path | None = field(default=None)
20
+
21
+ magic_string: str = field(default="TFB Script")
22
+ unk: bytes = field(
23
+ default=b"\x00\x00\x00\x00"
24
+ ) # 4 unknown bytes after the magic string
25
+
26
+ opcode_table: StringTable = field(default_factory=StringTable)
27
+ global_refs: StringTable = field(default_factory=StringTable)
28
+ local_refs: StringTable = field(default_factory=StringTable)
29
+
30
+ instructions: list[Opcode] = field(default_factory=list)
31
+
32
+ @classmethod
33
+ def read(
34
+ cls, reader: BinaryReader, debugOptions: dict[str, bool | int] | None = None
35
+ ) -> "ScriptFile":
36
+ """Read a ScriptFile from a binary reader."""
37
+
38
+ if debugOptions is None:
39
+ debugOptions = {}
40
+
41
+ debug_store: DebugStore = DebugStore()
42
+
43
+ magic_string = reader.read_string(reader.read_u8())
44
+ #print(magic_string)
45
+ unk = reader.read_bytes(4)
46
+
47
+ opcode_table = StringTable.read(reader)
48
+ global_refs = StringTable.read(reader)
49
+ local_refs = StringTable.read(reader)
50
+
51
+ instruction_count = reader.read_u32()
52
+ context = ParserContext(opcode_table, global_refs, local_refs)
53
+
54
+ instructions: list[Opcode] = []
55
+ instructions_read = 0
56
+ try:
57
+ while instructions_read < instruction_count:
58
+ instruction = Opcode.read(
59
+ reader, context, debug_store=debug_store, debugOptions=debugOptions
60
+ )
61
+ instructions.append(instruction)
62
+ # Count the instruction and its descendants.
63
+ instructions_read += instruction.total_span()
64
+ except Exception as error:
65
+ raise ValueError(
66
+ f"Error reading instruction {instructions_read}: {error}"
67
+ ) from error
68
+
69
+ if debugOptions.get("listUnresolvedOps"):
70
+ if len(debug_store.unresolved_ops) > 0:
71
+ print(f"Unresolved ops: {' '.join(debug_store.unresolved_ops)}")
72
+ else:
73
+ print("Unresolved ops: None \U0001f973") # None :party_emoji:
74
+
75
+ return cls(
76
+ magic_string=magic_string,
77
+ unk=unk,
78
+ opcode_table=opcode_table,
79
+ global_refs=global_refs,
80
+ local_refs=local_refs,
81
+ instructions=instructions,
82
+ )
83
+
84
+ def write(self, f: BinaryIO) -> None:
85
+ """Write the ScriptFile to a binary file."""
86
+
87
+ write_u8(f, len(self.magic_string))
88
+ f.write(self.magic_string.encode("latin1"))
89
+
90
+ # The 4 bytes after the magic are the opcode-arena size in dwords; see
91
+ # tfbscript/arena.py. Recompute it so edits that add instructions stay
92
+ # loadable, but never write a value smaller than the one the file came
93
+ # with: the field is an allocation size, so over-reserving is harmless
94
+ # while under-reserving would corrupt the loader's bump allocator.
95
+ computed = arena_dwords(self)
96
+ if computed is None:
97
+ f.write(self.unk)
98
+ else:
99
+ write_u32(f, max(computed, int.from_bytes(self.unk, "little")))
100
+
101
+ self.opcode_table.write(f)
102
+ self.global_refs.write(f)
103
+ self.local_refs.write(f)
104
+
105
+ instruction_count = sum(
106
+ instruction.total_span() for instruction in self.instructions
107
+ )
108
+
109
+ write_u32(f, instruction_count)
110
+ for instruction in self.instructions:
111
+ instruction.write(f)
112
+
113
+ @classmethod
114
+ def from_path(
115
+ cls, path: str | Path, debugOptions: dict[str, bool | int] | None = None
116
+ ) -> "ScriptFile":
117
+ """Read a ScriptFile from an .ai file on disk."""
118
+
119
+ if debugOptions is None:
120
+ debugOptions = {}
121
+
122
+ data = Path(path).read_bytes()
123
+
124
+ script = cls.read(
125
+ BinaryReader(data, little_endian=True), debugOptions=debugOptions
126
+ )
127
+ script._file_path = Path(path)
128
+ return script
129
+
130
+ @staticmethod
131
+ def _print_ref_table(title: str, table: StringTable) -> None:
132
+ """Print one reference table as `index: name::category::type [metadata]`."""
133
+ print(ansi.comment(f"----- {title} ({len(table)} entries) -----"))
134
+
135
+ for index, entry in enumerate(table.entries):
136
+ parts = [ansi.variable(entry.name)]
137
+ if entry.category is not None:
138
+ parts.append(ansi.builtin(entry.category))
139
+ parts.append(ansi.type_(entry.type))
140
+
141
+ metadata = entry.metadata.hex(" ")
142
+
143
+ print(
144
+ f" {ansi.number(f'{index:>3}')}: {'::'.join(parts)}"
145
+ + f" {ansi.comment(f'[{metadata}]')}"
146
+ )
147
+
148
+ def print_tree(self) -> None:
149
+ """Print the reference tables, then the whole script as indented pseudo-source."""
150
+ print()
151
+ self._print_ref_table("GLOBAL REFS", self.global_refs)
152
+ print()
153
+ self._print_ref_table("LOCAL REFS", self.local_refs)
154
+ print()
155
+ self._print_ref_table("OPCODES", self.opcode_table)
156
+ print()
157
+ print(ansi.comment("----- INSTRUCTIONS -----"))
158
+
159
+ for instruction in self.instructions:
160
+ instruction.print_tree()
@@ -0,0 +1,101 @@
1
+ """The three string tables at the head of every TFB script file."""
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from typing import TYPE_CHECKING, BinaryIO
6
+
7
+ from tfbscript.binary import write_u32, write_u8
8
+
9
+ if TYPE_CHECKING:
10
+ from tfbscript.binary import BinaryReader
11
+
12
+
13
+ @dataclass
14
+ class StringTableEntry:
15
+ """One entry: a length-prefixed string plus 4 bytes of metadata.
16
+
17
+ Entry strings are `::`-separated paths such as ``myactor::actor`` or
18
+ ``myactor::user::value`` -- the first part is the name, the last part the
19
+ type, and the middle part (if any) the category.
20
+ """
21
+
22
+ string: str = field(default="")
23
+ metadata: bytes = field(default=b"\x00\x00\x00\x00") # 4 bytes, usually zero
24
+
25
+ _split: list[str] = field(default_factory=list)
26
+
27
+ def _split_myself(self) -> list[str]:
28
+ if not self._split:
29
+ self._split = re.split(r'(?=::(?!:))', self.string)
30
+ self._split = [self._split[0]] + [p[2:] for p in self._split[1:]]
31
+
32
+
33
+ if len(self._split) < 2:
34
+ raise ValueError(f"String table entry '{self.string}' does not have a type part")
35
+
36
+ if len(self._split) > 3:
37
+ raise ValueError(f"String table entry '{self.string}' has too many parts")
38
+
39
+ return self._split
40
+
41
+ def __post_init__(self):
42
+ self._split_myself()
43
+
44
+ @classmethod
45
+ def read(cls, reader: "BinaryReader") -> "StringTableEntry":
46
+ length = reader.read_u8()
47
+ string = reader.read_string(length)
48
+ metadata = reader.read_bytes(4)
49
+
50
+ return cls(string, metadata)
51
+
52
+ @property
53
+ def name(self) -> str:
54
+ # ABC::user::value
55
+ return self._split[0]
56
+
57
+ @property
58
+ def category(self) -> str | None:
59
+ # abc::USER::value
60
+ return self._split[-2] if len(self._split) > 2 else None
61
+
62
+ @property
63
+ def type(self) -> str:
64
+ # abc::user::VALUE
65
+ return self._split[-1]
66
+
67
+ @dataclass
68
+ class StringTable:
69
+ """A u32 entry count followed by that many entries."""
70
+
71
+ entries: list[StringTableEntry] = field(default_factory=list)
72
+
73
+ @classmethod
74
+ def read(cls, reader: "BinaryReader") -> "StringTable":
75
+ count = reader.read_u32()
76
+ return cls([StringTableEntry.read(reader) for _ in range(count)])
77
+
78
+ def write(self, f: BinaryIO) -> None:
79
+ """Write the ScriptFile to a binary file."""
80
+
81
+ write_u32(f, len(self.entries))
82
+ for entry in self.entries:
83
+ write_u8(f, len(entry.string))
84
+ f.write(entry.string.encode("latin1"))
85
+ f.write(entry.metadata)
86
+
87
+ def __len__(self) -> int:
88
+ return len(self.entries)
89
+
90
+ def get(self, index: int) -> StringTableEntry | None:
91
+ if 0 <= index < len(self.entries):
92
+ return self.entries[index]
93
+ return None
94
+
95
+ def all_with_type(self, type_name: str) -> list[StringTableEntry]:
96
+ """All entries whose type (last ``::`` part) matches, e.g. ``behavior``."""
97
+ return [e for e in self.entries if e.type == type_name]
98
+
99
+ def all_with_category(self, category_name: str) -> list[StringTableEntry]:
100
+ """All entries whose category (middle ``::`` part) matches, e.g. ``user``."""
101
+ return [e for e in self.entries if e.category == category_name]