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.
- tfbscript/__init__.py +21 -0
- tfbscript/__main__.py +52 -0
- tfbscript/ansi.py +203 -0
- tfbscript/arena.py +119 -0
- tfbscript/binary.py +84 -0
- tfbscript/debug.py +14 -0
- tfbscript/editor/__init__.py +3 -0
- tfbscript/editor/app.py +141 -0
- tfbscript/editor/fonts/ms-sans-serif/MS Sans Serif.ttf +0 -0
- tfbscript/editor/fonts/ms-sans-serif/license.txt +4 -0
- tfbscript/editor/fonts/ms-sans-serif/readme.txt +26 -0
- tfbscript/editor/fonts/ms-sans-serif-bold/MS Sans Serif Bold.ttf +0 -0
- tfbscript/editor/fonts/ms-sans-serif-bold/license.txt +4 -0
- tfbscript/editor/fonts/ms-sans-serif-bold/readme.txt +26 -0
- tfbscript/editor/fonts.py +98 -0
- tfbscript/editor/search.py +200 -0
- tfbscript/editor/style.py +141 -0
- tfbscript/editor/widgets.py +321 -0
- tfbscript/opcodes/__init__.py +104 -0
- tfbscript/opcodes/base.py +362 -0
- tfbscript/opcodes/block.py +34 -0
- tfbscript/opcodes/enums.py +219 -0
- tfbscript/opcodes/op_behavior_implementation.py +39 -0
- tfbscript/opcodes/op_change_membership.py +62 -0
- tfbscript/opcodes/op_check_fov.py +73 -0
- tfbscript/opcodes/op_check_membership.py +41 -0
- tfbscript/opcodes/op_check_message.py +79 -0
- tfbscript/opcodes/op_check_reference.py +61 -0
- tfbscript/opcodes/op_check_value.py +66 -0
- tfbscript/opcodes/op_comment.py +43 -0
- tfbscript/opcodes/op_control.py +80 -0
- tfbscript/opcodes/op_create_variable.py +42 -0
- tfbscript/opcodes/op_cut_scene.py +44 -0
- tfbscript/opcodes/op_dec_value.py +43 -0
- tfbscript/opcodes/op_displace.py +111 -0
- tfbscript/opcodes/op_find_subset.py +49 -0
- tfbscript/opcodes/op_find_variable.py +28 -0
- tfbscript/opcodes/op_for_each.py +65 -0
- tfbscript/opcodes/op_if_else.py +105 -0
- tfbscript/opcodes/op_inc_value.py +43 -0
- tfbscript/opcodes/op_loop_value.py +28 -0
- tfbscript/opcodes/op_move_from.py +47 -0
- tfbscript/opcodes/op_move_to.py +99 -0
- tfbscript/opcodes/op_play_animation.py +46 -0
- tfbscript/opcodes/op_play_sound.py +42 -0
- tfbscript/opcodes/op_prescript.py +26 -0
- tfbscript/opcodes/op_print.py +53 -0
- tfbscript/opcodes/op_remove.py +43 -0
- tfbscript/opcodes/op_reset.py +42 -0
- tfbscript/opcodes/op_run_as_player.py +26 -0
- tfbscript/opcodes/op_send_message.py +53 -0
- tfbscript/opcodes/op_set_behavior.py +41 -0
- tfbscript/opcodes/op_set_reference.py +52 -0
- tfbscript/opcodes/op_set_value.py +52 -0
- tfbscript/opcodes/op_shutdown.py +26 -0
- tfbscript/opcodes/op_slide_value.py +81 -0
- tfbscript/opcodes/op_spawn_actor.py +96 -0
- tfbscript/opcodes/op_startup.py +26 -0
- tfbscript/opcodes/op_stop_sound.py +26 -0
- tfbscript/opcodes/op_teleport_to.py +47 -0
- tfbscript/opcodes/op_turn_to.py +33 -0
- tfbscript/opcodes/op_use_camera.py +68 -0
- tfbscript/payload.py +40 -0
- tfbscript/reference.py +926 -0
- tfbscript/rhs.py +326 -0
- tfbscript/script.py +160 -0
- tfbscript/string_table.py +101 -0
- tfbscript-0.1.0.dist-info/METADATA +116 -0
- tfbscript-0.1.0.dist-info/RECORD +72 -0
- tfbscript-0.1.0.dist-info/WHEEL +4 -0
- tfbscript-0.1.0.dist-info/entry_points.txt +2 -0
- 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]
|