rpgsync 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.
- rpgsync/__init__.py +8 -0
- rpgsync/__main__.py +3 -0
- rpgsync/cli.py +1242 -0
- rpgsync/commands.py +920 -0
- rpgsync/compat.py +203 -0
- rpgsync/database.py +1025 -0
- rpgsync/db.py +1377 -0
- rpgsync/dbschema.py +1011 -0
- rpgsync/dsl.py +2616 -0
- rpgsync/dynparams.py +357 -0
- rpgsync/dynrpg.py +612 -0
- rpgsync/fmt.py +79 -0
- rpgsync/lcf.py +826 -0
- rpgsync/maniac.py +387 -0
- rpgsync/maptree.py +888 -0
- rpgsync/messages.py +113 -0
- rpgsync/named.py +1051 -0
- rpgsync/project.py +323 -0
- rpgsync/py.typed +0 -0
- rpgsync/pyexpr.py +1472 -0
- rpgsync/resources.py +455 -0
- rpgsync/scaffold.py +567 -0
- rpgsync/script.py +3532 -0
- rpgsync/scriptfiles.py +924 -0
- rpgsync/sync.py +1365 -0
- rpgsync/tables.py +114 -0
- rpgsync/venv.py +102 -0
- rpgsync-0.1.0.dist-info/METADATA +288 -0
- rpgsync-0.1.0.dist-info/RECORD +31 -0
- rpgsync-0.1.0.dist-info/WHEEL +4 -0
- rpgsync-0.1.0.dist-info/entry_points.txt +3 -0
rpgsync/database.py
ADDED
|
@@ -0,0 +1,1025 @@
|
|
|
1
|
+
"""Database tables of RPG_RT.ldb <-> typed Python files.
|
|
2
|
+
|
|
3
|
+
Every exported table becomes one file holding a list of pydantic models
|
|
4
|
+
(``rpgsync/db.py``, generated from liblcf's schema)::
|
|
5
|
+
|
|
6
|
+
\"\"\"Enemies - RPG_RT.ldb\"\"\"
|
|
7
|
+
from rpgsync.db import *
|
|
8
|
+
|
|
9
|
+
enemies = [
|
|
10
|
+
Enemy(id=1, name="Slime", battler_name="Slime", max_hp=10, attack=8),
|
|
11
|
+
...
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
Files are parsed with :mod:`ast` (never executed), but they are valid Python
|
|
15
|
+
so an IDE can type check them against the models. Only non-default fields
|
|
16
|
+
are written; empty slots are left out.
|
|
17
|
+
|
|
18
|
+
Writing back is a patch: a chunk is only re-encoded when its value changed,
|
|
19
|
+
so untouched entries, fields of other files (troop battle events, written by
|
|
20
|
+
database/troop_events.py) and unknown chunks stay byte-for-byte identical. Like RPG_RT and EasyRPG, the
|
|
21
|
+
tables are indexed by position (id N is element N), so they never get holes:
|
|
22
|
+
an entry missing from a file is reset to an empty slot, new ids past the end
|
|
23
|
+
are appended (gaps are filled with empty slots).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import ast
|
|
29
|
+
import copy
|
|
30
|
+
import keyword
|
|
31
|
+
import re
|
|
32
|
+
from collections.abc import Callable
|
|
33
|
+
from functools import cache
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from pydantic import BaseModel, ValidationError
|
|
37
|
+
|
|
38
|
+
from . import db as M
|
|
39
|
+
from .commands import CompileError, Ctx, TableSize, config_src, pystr, size_stmt, split_size, table_length
|
|
40
|
+
from .dbschema import ENUMS, FLAGS, INDEXED, MODEL_STRUCTS, STRUCTS, FieldDef
|
|
41
|
+
from .dbschema import TABLES as _TABLE_INFO
|
|
42
|
+
from .lcf import ArrayItem, IntVector, LcfError, Struct
|
|
43
|
+
|
|
44
|
+
#: Exported tables, in file order. Tables missing from a database (classes
|
|
45
|
+
#: and battler animations in RPG Maker 2000) are skipped.
|
|
46
|
+
TABLES: list[str] = list(_TABLE_INFO)
|
|
47
|
+
|
|
48
|
+
#: Model classes that may appear in database files.
|
|
49
|
+
MODEL_CLASSES: dict[str, type] = {name: getattr(M, name) for name in MODEL_STRUCTS}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _camel(name: str) -> str:
|
|
53
|
+
return "".join(p[:1].upper() + p[1:] for p in name.split("_"))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
MODEL_CLASSES.update({_camel(fl): getattr(M, _camel(fl)) for fl in FLAGS if hasattr(M, _camel(fl))})
|
|
57
|
+
|
|
58
|
+
HEADER_NOTE = """\
|
|
59
|
+
# Edit freely: changes are written back to {target} by rpgsync.
|
|
60
|
+
# Every attribute of the class is an entry, in any order; its id is its slot
|
|
61
|
+
# in the editor. An entry without id gets the next free slot, a removed entry
|
|
62
|
+
# leaves an empty slot. Attribute names are yours: rename them with your IDE."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# class of each table file: (class name, base class in rpgsync.tables)
|
|
66
|
+
TABLE_CLASSES = {
|
|
67
|
+
"variables": ("Variables", "VariableTable"),
|
|
68
|
+
"switches": ("Switches", "SwitchTable"),
|
|
69
|
+
"battleranimations": ("BattlerAnimations", "Table"),
|
|
70
|
+
}
|
|
71
|
+
# entries written Model(id, "name") instead of Model(id=..., name=...)
|
|
72
|
+
POSITIONAL = ("Variable", "Switch")
|
|
73
|
+
|
|
74
|
+
WIDTH = 120
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _flags_class(name: str) -> type:
|
|
78
|
+
return MODEL_CLASSES[_camel(name)]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def table_struct(name: str) -> str:
|
|
82
|
+
"""Model / LCF structure name of a table ("enemies" -> "Enemy")."""
|
|
83
|
+
return _TABLE_INFO[name][1]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def db_engine(db: Struct) -> str:
|
|
87
|
+
""" "2k3" when the database has RPG Maker 2003 chunks, else "2k"."""
|
|
88
|
+
return "2k3" if (db._chunk(0x1D) is not None or db._chunk(0x1E) is not None) else "2k"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def has_table(db: Struct, name: str) -> bool:
|
|
92
|
+
return db._chunk(_TABLE_INFO[name][0]) is not None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# --------------------------------------------------------------------------
|
|
96
|
+
# Schema helpers
|
|
97
|
+
# --------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@cache
|
|
101
|
+
def _fields(sname: str) -> tuple[FieldDef, ...]:
|
|
102
|
+
"""Exported fields of a structure (no size chunks, no opaque fields)."""
|
|
103
|
+
return tuple(f for f in STRUCTS[sname] if f.size_of is None and f.model != "raw")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@cache
|
|
107
|
+
def _class_default(sname: str, fname: str) -> Any:
|
|
108
|
+
return MODEL_CLASSES[sname].model_fields[fname].get_default(call_default_factory=True)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _engine_dependent(f: FieldDef) -> bool:
|
|
112
|
+
return f.default != f.default_2k3
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _plain(v: Any) -> Any:
|
|
116
|
+
"""Comparable value: models become dicts (ignores private attributes)."""
|
|
117
|
+
if isinstance(v, BaseModel):
|
|
118
|
+
return v.model_dump()
|
|
119
|
+
if isinstance(v, list):
|
|
120
|
+
return [_plain(x) for x in v]
|
|
121
|
+
return v
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _enum_name(enum: str, v: int):
|
|
125
|
+
for k, x in ENUMS[enum].items():
|
|
126
|
+
if x == v:
|
|
127
|
+
return k
|
|
128
|
+
return v
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _enum_value(enum: str, v) -> int:
|
|
132
|
+
if isinstance(v, str):
|
|
133
|
+
if v not in ENUMS[enum]:
|
|
134
|
+
raise ValueError("%r is not one of %s" % (v, ", ".join(ENUMS[enum])))
|
|
135
|
+
return ENUMS[enum][v]
|
|
136
|
+
return v
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _check_range(v: int, lo: int, hi: int, what: str) -> int:
|
|
140
|
+
if isinstance(v, bool) or not isinstance(v, int) or not lo <= v <= hi:
|
|
141
|
+
raise ValueError("%s value %r out of range %d..%d" % (what, v, lo, hi))
|
|
142
|
+
return v
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
INT32 = (-0x80000000, 0x7FFFFFFF)
|
|
146
|
+
RANGES = {"u8": (0, 255, "byte"), "i16": (-0x8000, 0x7FFF, "16 bit"), "i32": INT32 + ("32 bit",)}
|
|
147
|
+
PARAMS = ("maxhp", "maxsp", "attack", "defense", "spirit", "agility")
|
|
148
|
+
EQUIP = ("weapon_id", "shield_id", "armor_id", "helmet_id", "accessory_id")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# Values the engines cannot use: (lowest, highest) per engine, None = unbounded.
|
|
152
|
+
# Upper bounds are the engine caps (EasyRPG Game_Constants); values above
|
|
153
|
+
# them are clamped by the engine, so they are only reported when they can
|
|
154
|
+
# never be reached.
|
|
155
|
+
_STAT = {"2k": (1, 999), "2k3": (1, 999)}
|
|
156
|
+
LIMITS: dict[str, dict[str, dict[str, tuple[int | None, int | None]]]] = {
|
|
157
|
+
"Enemy": {
|
|
158
|
+
"max_hp": {"*": (1, None)},
|
|
159
|
+
"max_sp": {"*": (0, None)},
|
|
160
|
+
**{k: {"*": (0, None)} for k in ("attack", "defense", "spirit", "agility", "exp", "gold")},
|
|
161
|
+
"drop_prob": {"*": (0, 100)},
|
|
162
|
+
"critical_hit_chance": {"*": (1, None)},
|
|
163
|
+
"battler_hue": {"*": (0, 360)},
|
|
164
|
+
},
|
|
165
|
+
"Actor": {
|
|
166
|
+
"initial_level": {"2k": (1, 50), "2k3": (1, 99)},
|
|
167
|
+
"final_level": {"2k": (1, 50), "2k3": (1, 99)},
|
|
168
|
+
"character_index": {"*": (0, 7)},
|
|
169
|
+
"face_index": {"*": (0, 15)},
|
|
170
|
+
"critical_hit_chance": {"*": (1, None)},
|
|
171
|
+
},
|
|
172
|
+
"Item": {
|
|
173
|
+
**{k: {"*": (0, None)} for k in ("price", "uses", "sp_cost")},
|
|
174
|
+
"hit": {"*": (0, 100)},
|
|
175
|
+
"critical_hit": {"*": (0, 100)},
|
|
176
|
+
},
|
|
177
|
+
"Skill": {"sp_cost": {"*": (0, None)}},
|
|
178
|
+
"Parameters": {
|
|
179
|
+
"maxhp": {"2k": (1, 999), "2k3": (1, 9999)},
|
|
180
|
+
"maxsp": {"*": (0, 999)},
|
|
181
|
+
**{k: _STAT for k in ("attack", "defense", "spirit", "agility")},
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _check_limits(model: BaseModel, sname: str, engine: str, node: ast.AST) -> None:
|
|
187
|
+
for fname, by_engine in LIMITS.get(sname, {}).items():
|
|
188
|
+
lo, hi = by_engine.get(engine) or by_engine.get("*") or (None, None)
|
|
189
|
+
v = getattr(model, fname, None)
|
|
190
|
+
for x in v if isinstance(v, list) else [v]:
|
|
191
|
+
if not isinstance(x, int) or isinstance(x, bool):
|
|
192
|
+
continue
|
|
193
|
+
if (lo is not None and x < lo) or (hi is not None and x > hi):
|
|
194
|
+
bounds = "%s..%s" % (lo, hi) if hi is not None else "at least %d" % lo
|
|
195
|
+
raise CompileError("%s(): %s: %d is out of range (%s)" % (sname, fname, x, bounds), node)
|
|
196
|
+
for f in type(model).model_fields:
|
|
197
|
+
sub = getattr(model, f, None)
|
|
198
|
+
if isinstance(sub, BaseModel) and type(sub).__name__ in LIMITS:
|
|
199
|
+
_check_limits(sub, type(sub).__name__, engine, node)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# --------------------------------------------------------------------------
|
|
203
|
+
# LCF value <-> model value
|
|
204
|
+
# --------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _to_model(f: FieldDef, v: Any, ctx: Ctx) -> Any:
|
|
208
|
+
m = f.model
|
|
209
|
+
if m == "int":
|
|
210
|
+
return v
|
|
211
|
+
if m == "bool":
|
|
212
|
+
return bool(v)
|
|
213
|
+
if m == "str":
|
|
214
|
+
return ctx.dec(bytes(v))
|
|
215
|
+
if m.startswith("enum:"):
|
|
216
|
+
return _enum_name(m[5:], v)
|
|
217
|
+
if m == "bits":
|
|
218
|
+
return [bool(x) for x in v]
|
|
219
|
+
if m in RANGES:
|
|
220
|
+
return list(v)
|
|
221
|
+
if m == "params":
|
|
222
|
+
n = len(v) // 6
|
|
223
|
+
return M.Parameters.model_construct(**{k: list(v[i * n : (i + 1) * n]) for i, k in enumerate(PARAMS)})
|
|
224
|
+
if m == "equip":
|
|
225
|
+
vals = list(v[:5]) + [0] * (5 - len(v[:5]))
|
|
226
|
+
return M.Equipment.model_construct(**dict(zip(EQUIP, vals)))
|
|
227
|
+
if m.startswith("flags:"):
|
|
228
|
+
out = {}
|
|
229
|
+
for i, (name, _) in enumerate(FLAGS[m[6:]]):
|
|
230
|
+
out[name] = bool(i // 8 < len(v) and v[i // 8] >> (i % 8) & 1)
|
|
231
|
+
return _flags_class(m[6:]).model_construct(**out)
|
|
232
|
+
if m.startswith("struct:"):
|
|
233
|
+
return struct_to_model(v, m[7:], ctx)
|
|
234
|
+
if m.startswith("array:"):
|
|
235
|
+
return [struct_to_model(it.struct, m[6:], ctx, it.id) for it in v]
|
|
236
|
+
raise LcfError("no model conversion for %s" % m)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _to_lcf(f: FieldDef, v: Any, ctx: Ctx, cur: Any = None) -> Any:
|
|
240
|
+
"""Model value -> LCF value. `cur` is the current LCF value (or None);
|
|
241
|
+
bits that the model does not represent are taken from it."""
|
|
242
|
+
m = f.model
|
|
243
|
+
if m == "int":
|
|
244
|
+
return _check_range(v, *INT32, f.name)
|
|
245
|
+
if m == "bool":
|
|
246
|
+
if cur is not None and bool(cur) == bool(v):
|
|
247
|
+
return cur
|
|
248
|
+
return bool(v)
|
|
249
|
+
if m == "str":
|
|
250
|
+
return ctx.enc(v)
|
|
251
|
+
if m.startswith("enum:"):
|
|
252
|
+
return _check_range(_enum_value(m[5:], v), *INT32, f.name)
|
|
253
|
+
if m == "bits":
|
|
254
|
+
out = []
|
|
255
|
+
for i, b in enumerate(v):
|
|
256
|
+
if cur is not None and i < len(cur) and bool(cur[i]) == bool(b):
|
|
257
|
+
out.append(cur[i])
|
|
258
|
+
else:
|
|
259
|
+
out.append(1 if b else 0)
|
|
260
|
+
return IntVector(out)
|
|
261
|
+
if m in RANGES:
|
|
262
|
+
lo, hi, what = RANGES[m]
|
|
263
|
+
return IntVector(_check_range(x, lo, hi, "%s (%s)" % (f.name, what)) for x in v)
|
|
264
|
+
if m == "params":
|
|
265
|
+
lists = [getattr(v, k) for k in PARAMS]
|
|
266
|
+
if len({len(x) for x in lists}) > 1:
|
|
267
|
+
raise ValueError(
|
|
268
|
+
"%s: maxhp, maxsp, attack, defense, spirit and agility need the same length "
|
|
269
|
+
"(one value per level)" % f.name
|
|
270
|
+
)
|
|
271
|
+
return IntVector(_check_range(x, -0x8000, 0x7FFF, "%s (16 bit)" % f.name) for x in sum(lists, []))
|
|
272
|
+
if m == "equip":
|
|
273
|
+
out = IntVector(_check_range(getattr(v, k), -0x8000, 0x7FFF, "%s (16 bit)" % f.name) for k in EQUIP)
|
|
274
|
+
if cur is not None and len(cur) > 5:
|
|
275
|
+
out.extend(cur[5:])
|
|
276
|
+
if cur is not None:
|
|
277
|
+
out.trailer = getattr(cur, "trailer", b"")
|
|
278
|
+
return out
|
|
279
|
+
if m.startswith("flags:"):
|
|
280
|
+
flags = FLAGS[m[6:]]
|
|
281
|
+
data = list(cur) if cur is not None else []
|
|
282
|
+
need = (len(flags) + 7) // 8
|
|
283
|
+
data += [0] * (need - len(data))
|
|
284
|
+
for i, (name, _) in enumerate(flags):
|
|
285
|
+
if getattr(v, name):
|
|
286
|
+
data[i // 8] |= 1 << (i % 8)
|
|
287
|
+
else:
|
|
288
|
+
data[i // 8] &= ~(1 << (i % 8)) & 0xFF
|
|
289
|
+
return IntVector(data)
|
|
290
|
+
raise LcfError("no LCF conversion for %s" % m)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _cmp(f: FieldDef, v: Any, ctx: Ctx) -> Any:
|
|
294
|
+
"""Value used to decide whether a field changed."""
|
|
295
|
+
if f.model == "str":
|
|
296
|
+
return ctx.enc(v)
|
|
297
|
+
if f.model.startswith("enum:") and isinstance(v, str):
|
|
298
|
+
return ENUMS[f.model[5:]].get(v, v)
|
|
299
|
+
return _plain(v)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def struct_to_model(st: Struct, sname: str, ctx: Ctx, id: int | None = None) -> M.DbModel:
|
|
303
|
+
"""Decode a structure into its model (absent chunks get the model defaults)."""
|
|
304
|
+
vals: dict[str, Any] = {}
|
|
305
|
+
if sname in INDEXED:
|
|
306
|
+
vals["id"] = id
|
|
307
|
+
for f in _fields(sname):
|
|
308
|
+
if st._chunk(f.id) is None:
|
|
309
|
+
vals[f.name] = _class_default(sname, f.name)
|
|
310
|
+
else:
|
|
311
|
+
vals[f.name] = _to_model(f, st.get(f.id), ctx)
|
|
312
|
+
return MODEL_CLASSES[sname].model_construct(**vals)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# --------------------------------------------------------------------------
|
|
316
|
+
# New entries
|
|
317
|
+
# --------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def new_struct(sname: str, engine: str, actors: int = 0) -> Struct:
|
|
321
|
+
"""An empty entry laid out like the RPG Maker editor writes one: the
|
|
322
|
+
chunks liblcf marks PersistIfDefault, with their default values, plus the
|
|
323
|
+
editor's initial stat curves / battle commands / battle animations.
|
|
324
|
+
`actors` is the number of actors (a new 2003 skill gets one battle
|
|
325
|
+
animation setting per actor)."""
|
|
326
|
+
st = Struct(sname)
|
|
327
|
+
for f in STRUCTS[sname]:
|
|
328
|
+
if f.id is None or f.size_of is not None or not f.persist:
|
|
329
|
+
continue
|
|
330
|
+
if f.is2k3 and engine != "2k3":
|
|
331
|
+
continue
|
|
332
|
+
value = _default_lcf(f, engine)
|
|
333
|
+
if value is not None:
|
|
334
|
+
st.set(f.id, value)
|
|
335
|
+
if sname == "Actor":
|
|
336
|
+
levels = 99 if engine == "2k3" else 50
|
|
337
|
+
st.set("parameters", IntVector([1] * levels + [0] * levels + [1] * (4 * levels)))
|
|
338
|
+
if engine == "2k3":
|
|
339
|
+
st.set("battle_commands", IntVector([-1] * 7))
|
|
340
|
+
if sname == "Item" and engine == "2k3":
|
|
341
|
+
st.set("animation_data", [ArrayItem(id=1, struct=new_struct("BattlerAnimationItemSkill", engine))])
|
|
342
|
+
if sname == "Skill" and engine == "2k3":
|
|
343
|
+
st.set(
|
|
344
|
+
"battler_animation_data",
|
|
345
|
+
[ArrayItem(id=n, struct=new_struct("BattlerAnimationItemSkill", engine)) for n in range(1, actors + 1)],
|
|
346
|
+
)
|
|
347
|
+
return st
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _default_lcf(f: FieldDef, engine: str) -> Any:
|
|
351
|
+
d = f.default_2k3 if engine == "2k3" else f.default
|
|
352
|
+
if f.kind in ("int", "bool"):
|
|
353
|
+
if f.model.startswith("enum:"):
|
|
354
|
+
return _enum_value(f.model[5:], d)
|
|
355
|
+
return d if d is not None else 0
|
|
356
|
+
if f.kind == "str":
|
|
357
|
+
return (d or "").encode("ascii")
|
|
358
|
+
if f.model == "equip":
|
|
359
|
+
return IntVector([0] * 5)
|
|
360
|
+
if f.kind in ("i16vec", "i32vec", "u8vec"):
|
|
361
|
+
return IntVector(d if isinstance(d, list) else []) # e.g. a map's area rectangle: 4 zeros
|
|
362
|
+
if f.kind.startswith("S:"):
|
|
363
|
+
return new_struct(f.kind[2:], engine)
|
|
364
|
+
if f.kind.startswith("A:"):
|
|
365
|
+
return []
|
|
366
|
+
if f.kind == "raw" and f.type.startswith("Array<"):
|
|
367
|
+
return b"\x00" # empty opaque array (e.g. troop pages)
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@cache
|
|
372
|
+
def _new_model_plain(sname: str, engine: str) -> dict[str, Any]:
|
|
373
|
+
return struct_to_model(new_struct(sname, engine), sname, Ctx(encoding="ascii")).model_dump()
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _is_default_element(e: M.DbModel) -> bool:
|
|
377
|
+
return e.model_dump(exclude={"id"}) == type(e)().model_dump(exclude={"id"})
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def is_empty_entry(model: M.DbModel, sname: str, engine: str) -> bool:
|
|
381
|
+
"""An unused slot: every field is the default, or what the editor writes
|
|
382
|
+
for a new entry (arrays of default elements count as empty)."""
|
|
383
|
+
fresh = _new_model_plain(sname, engine)
|
|
384
|
+
for f in _fields(sname):
|
|
385
|
+
v = _plain(getattr(model, f.name))
|
|
386
|
+
if v == _plain(_class_default(sname, f.name)) or v == fresh[f.name]:
|
|
387
|
+
continue
|
|
388
|
+
if f.model.startswith("array:") and all(_is_default_element(e) for e in getattr(model, f.name)):
|
|
389
|
+
continue
|
|
390
|
+
return False
|
|
391
|
+
return True
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# --------------------------------------------------------------------------
|
|
395
|
+
# Patching
|
|
396
|
+
# --------------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def patch_struct(
|
|
400
|
+
st: Struct, sname: str, model: M.DbModel, ctx: Ctx, engine: str, keep_new_defaults: bool = False
|
|
401
|
+
) -> bool:
|
|
402
|
+
"""Make `st` carry `model`, re-encoding only the chunks whose value
|
|
403
|
+
changed. Returns True when something changed.
|
|
404
|
+
|
|
405
|
+
With `keep_new_defaults` (filling an empty slot), fields left at their
|
|
406
|
+
model default keep the value of the empty slot, so a new actor written
|
|
407
|
+
as ``Actor(id=9, name="Bob")`` gets the editor's initial stat curves."""
|
|
408
|
+
changed = False
|
|
409
|
+
for f in _fields(sname):
|
|
410
|
+
target = getattr(model, f.name)
|
|
411
|
+
if keep_new_defaults and _plain(target) == _plain(_class_default(sname, f.name)):
|
|
412
|
+
continue
|
|
413
|
+
present = st._chunk(f.id) is not None
|
|
414
|
+
m = f.model
|
|
415
|
+
if m.startswith("struct:"):
|
|
416
|
+
sub = st.get(f.id) if present else new_struct(m[7:], engine)
|
|
417
|
+
if patch_struct(sub, m[7:], target, ctx, engine):
|
|
418
|
+
st.set(f.id, sub)
|
|
419
|
+
changed = True
|
|
420
|
+
elif m.startswith("array:"):
|
|
421
|
+
items = list(st.get(f.id)) if present else []
|
|
422
|
+
new, ch = _patch_array(items, m[6:], target, ctx, engine)
|
|
423
|
+
if ch:
|
|
424
|
+
st.set(f.id, new)
|
|
425
|
+
changed = True
|
|
426
|
+
elif _engine_dependent(f) and target is None:
|
|
427
|
+
if present:
|
|
428
|
+
st.remove(f.id)
|
|
429
|
+
changed = True
|
|
430
|
+
else:
|
|
431
|
+
cur = st.get(f.id) if present else None
|
|
432
|
+
cur_model = _to_model(f, cur, ctx) if present else _class_default(sname, f.name)
|
|
433
|
+
if cur_model is None or _cmp(f, cur_model, ctx) != _cmp(f, target, ctx):
|
|
434
|
+
st.set(f.id, _to_lcf(f, target, ctx, cur))
|
|
435
|
+
changed = True
|
|
436
|
+
return changed
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _patch_array(
|
|
440
|
+
items: list[ArrayItem], ename: str, targets: list[M.DbModel], ctx: Ctx, engine: str
|
|
441
|
+
) -> tuple[list[ArrayItem], bool]:
|
|
442
|
+
"""Patch array elements by position."""
|
|
443
|
+
changed = len(items) != len(targets)
|
|
444
|
+
out = []
|
|
445
|
+
for i, t in enumerate(targets):
|
|
446
|
+
tid = t.id if getattr(t, "id", None) is not None else i + 1
|
|
447
|
+
if i < len(items):
|
|
448
|
+
it = items[i]
|
|
449
|
+
if it.id != tid:
|
|
450
|
+
it.id = tid
|
|
451
|
+
changed = True
|
|
452
|
+
else:
|
|
453
|
+
it = ArrayItem(id=tid, struct=new_struct(ename, engine))
|
|
454
|
+
changed = True
|
|
455
|
+
if patch_struct(it.struct, ename, t, ctx, engine):
|
|
456
|
+
changed = True
|
|
457
|
+
out.append(it)
|
|
458
|
+
return out, changed
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# --------------------------------------------------------------------------
|
|
462
|
+
# Tables
|
|
463
|
+
# --------------------------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def table_entries_from_bin(db: Struct, name: str, ctx: Ctx) -> list[M.DbModel]:
|
|
467
|
+
"""The used entries of a table (empty slots are not listed)."""
|
|
468
|
+
if not has_table(db, name):
|
|
469
|
+
return []
|
|
470
|
+
sname = table_struct(name)
|
|
471
|
+
engine = db_engine(db)
|
|
472
|
+
out = []
|
|
473
|
+
for it in db.get(name):
|
|
474
|
+
model = struct_to_model(it.struct, sname, ctx, it.id)
|
|
475
|
+
if not is_empty_entry(model, sname, engine):
|
|
476
|
+
out.append(model)
|
|
477
|
+
return out
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _keep_opaque(new: Struct, old: Struct, sname: str) -> Struct:
|
|
481
|
+
"""`new` with the unexported chunks of `old` (a troop's battle events): another
|
|
482
|
+
script owns them (database/troop_events.py), so resetting the entry keeps them."""
|
|
483
|
+
for f in STRUCTS[sname]:
|
|
484
|
+
if f.id is not None and f.model == "raw" and f.size_of is None:
|
|
485
|
+
c = old._chunk(f.id)
|
|
486
|
+
if c is not None:
|
|
487
|
+
new.set(f.id, old.get(f.id))
|
|
488
|
+
return new
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def apply_table(db: Struct, name: str, entries: list[M.DbModel], ctx: Ctx) -> dict[str, list[int]]:
|
|
492
|
+
"""Patch a table of the database to hold exactly `entries`.
|
|
493
|
+
|
|
494
|
+
Entries are looked up by position, so the table never gets holes: an
|
|
495
|
+
entry missing from `entries` is reset to an empty slot, ids past the end
|
|
496
|
+
are appended and gaps are filled with empty slots."""
|
|
497
|
+
summary: dict[str, list[int]] = {"added": [], "removed": [], "changed": []}
|
|
498
|
+
if not has_table(db, name):
|
|
499
|
+
if entries:
|
|
500
|
+
raise ValueError("this database has no %s table (RPG Maker 2003 only)" % name)
|
|
501
|
+
return summary
|
|
502
|
+
entries, wanted = split_size(entries)
|
|
503
|
+
sname = table_struct(name)
|
|
504
|
+
engine = db_engine(db)
|
|
505
|
+
actors = len(db.get("actors")) if has_table(db, "actors") else 0
|
|
506
|
+
current = list(db.get(name))
|
|
507
|
+
if any(it.id != n for n, it in enumerate(current, 1)):
|
|
508
|
+
raise ValueError("the database's %s are not numbered 1..N; refusing to modify them" % name)
|
|
509
|
+
by_id: dict[int, M.DbModel] = {}
|
|
510
|
+
for e in entries:
|
|
511
|
+
if not isinstance(e.id, int) or isinstance(e.id, bool) or e.id < 1:
|
|
512
|
+
raise ValueError("%s: id must be a positive integer, got %r" % (name, e.id))
|
|
513
|
+
if e.id in by_id:
|
|
514
|
+
raise ValueError("%s: id %d is used twice" % (name, e.id))
|
|
515
|
+
by_id[e.id] = e
|
|
516
|
+
size = table_length(len(current), wanted, list(by_id))
|
|
517
|
+
items = []
|
|
518
|
+
for n in range(1, size + 1):
|
|
519
|
+
item = current[n - 1] if n <= len(current) else None
|
|
520
|
+
spec = by_id.get(n)
|
|
521
|
+
if item is None:
|
|
522
|
+
item = ArrayItem(id=n, struct=new_struct(sname, engine, actors))
|
|
523
|
+
if spec is not None:
|
|
524
|
+
patch_struct(item.struct, sname, spec, ctx, engine, keep_new_defaults=True)
|
|
525
|
+
summary["added"].append(n)
|
|
526
|
+
else:
|
|
527
|
+
was_empty = is_empty_entry(struct_to_model(item.struct, sname, ctx, n), sname, engine)
|
|
528
|
+
if spec is None:
|
|
529
|
+
if not was_empty:
|
|
530
|
+
item = ArrayItem(id=n, struct=_keep_opaque(new_struct(sname, engine, actors), item.struct, sname))
|
|
531
|
+
summary["removed"].append(n)
|
|
532
|
+
elif patch_struct(item.struct, sname, spec, ctx, engine, keep_new_defaults=was_empty):
|
|
533
|
+
summary["added" if was_empty else "changed"].append(n)
|
|
534
|
+
items.append(item)
|
|
535
|
+
if size != len(current):
|
|
536
|
+
summary["size"] = [len(current), size]
|
|
537
|
+
if summary["added"] or summary["removed"] or summary["changed"] or len(items) != len(current):
|
|
538
|
+
db.set(name, items)
|
|
539
|
+
return summary
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
# --------------------------------------------------------------------------
|
|
543
|
+
# Export
|
|
544
|
+
# --------------------------------------------------------------------------
|
|
545
|
+
#
|
|
546
|
+
# Values are rendered through a tiny layout tree so that long entries can be
|
|
547
|
+
# broken over several lines: ("atom", text) | ("call", name, [(kw, node)])
|
|
548
|
+
# | ("list", [node]) | ("mul", node, n)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _atom(v: Any) -> tuple:
|
|
552
|
+
if isinstance(v, str):
|
|
553
|
+
return ("atom", pystr(v))
|
|
554
|
+
return ("atom", repr(v))
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _list_node(nodes: list[tuple]) -> tuple:
|
|
558
|
+
if len(nodes) >= 3 and all(n == nodes[0] for n in nodes):
|
|
559
|
+
return ("mul", ("list", [nodes[0]]), len(nodes))
|
|
560
|
+
return ("list", nodes)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _model_node(model: BaseModel, sname: str, position: int | None = None, top: bool = False) -> tuple:
|
|
564
|
+
kws = []
|
|
565
|
+
if sname in INDEXED and (top or getattr(model, "id", None) not in (None, position)):
|
|
566
|
+
kws.append(("id", _atom(model.id)))
|
|
567
|
+
if sname in STRUCTS and sname not in ("Parameters", "Equipment"):
|
|
568
|
+
for f in _fields(sname):
|
|
569
|
+
v = getattr(model, f.name)
|
|
570
|
+
if _plain(v) == _plain(_class_default(sname, f.name)):
|
|
571
|
+
continue
|
|
572
|
+
kws.append((f.name, _value_node(f, v)))
|
|
573
|
+
else: # packed structures and flags: plain fields
|
|
574
|
+
defaults = type(model)()
|
|
575
|
+
for k in type(model).model_fields:
|
|
576
|
+
v = getattr(model, k)
|
|
577
|
+
if v != getattr(defaults, k):
|
|
578
|
+
kws.append((k, _list_node([_atom(x) for x in v]) if isinstance(v, list) else _atom(v)))
|
|
579
|
+
return ("call", type(model).__name__, kws)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _value_node(f: FieldDef, v: Any) -> tuple:
|
|
583
|
+
m = f.model
|
|
584
|
+
if m.startswith("struct:"):
|
|
585
|
+
return _model_node(v, m[7:])
|
|
586
|
+
if m in ("params", "equip") or m.startswith("flags:"):
|
|
587
|
+
return _model_node(v, type(v).__name__)
|
|
588
|
+
if m.startswith("array:"):
|
|
589
|
+
ename = m[6:]
|
|
590
|
+
nodes = [_model_node(e, ename, i + 1) for i, e in enumerate(v)]
|
|
591
|
+
positional = all(getattr(e, "id", None) == i + 1 for i, e in enumerate(v))
|
|
592
|
+
if positional and len(nodes) >= 2 and all(n == nodes[0] for n in nodes):
|
|
593
|
+
return ("mul", ("list", [nodes[0]]), len(nodes))
|
|
594
|
+
return ("list", nodes)
|
|
595
|
+
if isinstance(v, list):
|
|
596
|
+
return _list_node([_atom(x) for x in v])
|
|
597
|
+
return _atom(v)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _flat(n: tuple) -> str:
|
|
601
|
+
if n[0] == "atom":
|
|
602
|
+
return n[1]
|
|
603
|
+
if n[0] == "call":
|
|
604
|
+
return "%s(%s)" % (n[1], ", ".join("%s=%s" % (k, _flat(v)) for k, v in n[2]))
|
|
605
|
+
if n[0] == "list":
|
|
606
|
+
return "[%s]" % ", ".join(_flat(x) for x in n[1])
|
|
607
|
+
return "%s * %d" % (_flat(n[1]), n[2])
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _fmt(n: tuple, ind: int, col: int, tail: int = 1) -> str:
|
|
611
|
+
"""Render node `n` starting at column `col` on a line indented by `ind`;
|
|
612
|
+
`tail` characters follow it on its last line."""
|
|
613
|
+
flat = _flat(n)
|
|
614
|
+
if col + len(flat) + tail <= WIDTH or n[0] == "atom" or _is_row(n):
|
|
615
|
+
return flat
|
|
616
|
+
pad = " " * (ind + 4)
|
|
617
|
+
if n[0] == "mul":
|
|
618
|
+
return "%s * %d" % (_fmt(n[1], ind, col, tail + len(" * %d" % n[2])), n[2])
|
|
619
|
+
if n[0] == "call":
|
|
620
|
+
if not n[2]:
|
|
621
|
+
return flat
|
|
622
|
+
lines = ["%s(" % n[1]]
|
|
623
|
+
for k, v in n[2]:
|
|
624
|
+
lines.append("%s%s=%s," % (pad, k, _fmt(v, ind + 4, ind + 4 + len(k) + 1)))
|
|
625
|
+
lines.append(" " * ind + ")")
|
|
626
|
+
return "\n".join(lines)
|
|
627
|
+
lines = ["["]
|
|
628
|
+
for x in n[1]:
|
|
629
|
+
lines.append("%s%s," % (pad, _fmt(x, ind + 4, ind + 4)))
|
|
630
|
+
lines.append(" " * ind + "]")
|
|
631
|
+
return "\n".join(lines)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _is_row(n: tuple) -> bool:
|
|
635
|
+
"""Lists of plain values (stat curves, flags, ...) stay on one line,
|
|
636
|
+
however long: one row per field reads better than one value per line."""
|
|
637
|
+
if n[0] == "mul":
|
|
638
|
+
return _is_row(n[1])
|
|
639
|
+
return n[0] == "list" and all(x[0] == "atom" for x in n[1])
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def table_class(name: str) -> tuple[str, str]:
|
|
643
|
+
return TABLE_CLASSES.get(name, (_camel(name), "Table"))
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _snake(name: str) -> str:
|
|
647
|
+
return re.sub(r"(?<=[a-z0-9])([A-Z])", r"_\1", name).lower()
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def identifiers(name: str, names: dict[int, str], keep: dict[int, str] | None = None) -> dict[int, str]:
|
|
651
|
+
"""Attribute names of a table's entries: the ones already in the file
|
|
652
|
+
(`keep`), else derived from the entry names ("Code voulu" -> code_voulu),
|
|
653
|
+
else <entry>_<id>."""
|
|
654
|
+
from . import dsl
|
|
655
|
+
from .script import _slug
|
|
656
|
+
|
|
657
|
+
base = {"VariableTable": dsl.Variables, "SwitchTable": dsl.Switches}.get(table_class(name)[1], object)
|
|
658
|
+
reserved = set(dir(base)) | set(keyword.kwlist) | {"size", "Config"}
|
|
659
|
+
singular = _snake(table_struct(name))
|
|
660
|
+
out: dict[int, str] = {}
|
|
661
|
+
used: set[str] = set()
|
|
662
|
+
for n, ident in (keep or {}).items():
|
|
663
|
+
if n in names and ident.isidentifier() and ident not in reserved and ident not in used:
|
|
664
|
+
out[n] = ident
|
|
665
|
+
used.add(ident)
|
|
666
|
+
for n, entry_name in names.items():
|
|
667
|
+
if n in out:
|
|
668
|
+
continue
|
|
669
|
+
slug = _slug(entry_name)
|
|
670
|
+
if not slug or slug[0].isdigit() or slug in reserved:
|
|
671
|
+
slug = "%s_%d" % (singular, n)
|
|
672
|
+
if slug in used:
|
|
673
|
+
slug = base = "%s_%d" % (slug, n)
|
|
674
|
+
k = 2
|
|
675
|
+
while slug in used: # "Potion", "Potion 3", "Potion" (id 3): potion_3 is taken too
|
|
676
|
+
slug = "%s_%d" % (base, k)
|
|
677
|
+
k += 1
|
|
678
|
+
out[n] = slug
|
|
679
|
+
used.add(slug)
|
|
680
|
+
return out
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def read_identifiers(text: str) -> dict[int, str]:
|
|
684
|
+
"""id -> attribute name of the entries of a database file (best effort,
|
|
685
|
+
for keeping the names when the file is rewritten)."""
|
|
686
|
+
try:
|
|
687
|
+
tree = ast.parse(text)
|
|
688
|
+
except SyntaxError:
|
|
689
|
+
return {}
|
|
690
|
+
out = {}
|
|
691
|
+
for cls in tree.body:
|
|
692
|
+
if not isinstance(cls, ast.ClassDef):
|
|
693
|
+
continue
|
|
694
|
+
for stmt in cls.body:
|
|
695
|
+
if isinstance(stmt, ast.Assign) and len(stmt.targets) == 1 and isinstance(stmt.targets[0], ast.Name):
|
|
696
|
+
v = stmt.value
|
|
697
|
+
if isinstance(v, ast.Call):
|
|
698
|
+
first = v.args[0] if v.args else next((k.value for k in v.keywords if k.arg == "id"), None)
|
|
699
|
+
if isinstance(first, ast.Constant) and type(first.value) is int:
|
|
700
|
+
out[first.value] = stmt.targets[0].id
|
|
701
|
+
return out
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _entry_src(e: M.DbModel, sname: str, ident: str, skip_width: int | None = None) -> str:
|
|
705
|
+
if sname in POSITIONAL:
|
|
706
|
+
return "%s = %s(%d, %s)" % (ident, sname, e.id, pystr(e.name)) # type: ignore[attr-defined]
|
|
707
|
+
src = "%s = %s" % (ident, _fmt(_model_node(e, sname, top=True), 0, 4 + len(ident) + 3, 0))
|
|
708
|
+
if skip_width is not None and any(len(line) + 4 > skip_width for line in src.split("\n")):
|
|
709
|
+
src += " # fmt: skip" # keep the long lists on one line each
|
|
710
|
+
return src
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def render_table(
|
|
714
|
+
name: str,
|
|
715
|
+
entries: list[M.DbModel],
|
|
716
|
+
target: str = "RPG_RT.ldb",
|
|
717
|
+
size: int | None = None,
|
|
718
|
+
keep: dict[int, str] | None = None,
|
|
719
|
+
skip_width: int | None = None,
|
|
720
|
+
) -> str:
|
|
721
|
+
"""A database file: one class whose attributes are the entries.
|
|
722
|
+
`keep`: attribute names to reuse (id -> name), from the current file."""
|
|
723
|
+
sname = table_struct(name)
|
|
724
|
+
cls, base = table_class(name)
|
|
725
|
+
idents = identifiers(name, {e.id: getattr(e, "name", "") or "" for e in entries if e.id is not None}, keep)
|
|
726
|
+
out = [
|
|
727
|
+
'"""%s - %s"""' % (_TABLE_INFO[name][2], target),
|
|
728
|
+
"",
|
|
729
|
+
HEADER_NOTE.format(target=target),
|
|
730
|
+
"from rpgsync.tables import *",
|
|
731
|
+
"",
|
|
732
|
+
"",
|
|
733
|
+
"class %s(%s):" % (cls, base),
|
|
734
|
+
]
|
|
735
|
+
body = []
|
|
736
|
+
if size is not None:
|
|
737
|
+
body += config_src(size, "")
|
|
738
|
+
if entries:
|
|
739
|
+
body.append("")
|
|
740
|
+
for e in entries:
|
|
741
|
+
body.append(_entry_src(e, sname, idents[e.id], skip_width)) # type: ignore[index]
|
|
742
|
+
if not body:
|
|
743
|
+
body = ["pass"]
|
|
744
|
+
out += [" " + line if line else "" for line in "\n".join(body).split("\n")]
|
|
745
|
+
out += ["", "", "%s = %s()" % (name, cls)]
|
|
746
|
+
return "\n".join(out) + "\n"
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def export_tables(
|
|
750
|
+
db: Struct,
|
|
751
|
+
ctx: Ctx,
|
|
752
|
+
target: str = "RPG_RT.ldb",
|
|
753
|
+
keep: dict[str, dict[int, str]] | None = None,
|
|
754
|
+
skip_width: int | None = None,
|
|
755
|
+
tables: list[str] | None = None,
|
|
756
|
+
) -> dict[str, str]:
|
|
757
|
+
"""Table name -> file text, for every exported table present in `db`
|
|
758
|
+
(or only `tables`). `keep`: per table, the attribute names to reuse."""
|
|
759
|
+
return {
|
|
760
|
+
name: render_table(
|
|
761
|
+
name, table_entries_from_bin(db, name, ctx), target, len(db.get(name)), (keep or {}).get(name), skip_width
|
|
762
|
+
)
|
|
763
|
+
for name in TABLES
|
|
764
|
+
if has_table(db, name) and (tables is None or name in tables)
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
# --------------------------------------------------------------------------
|
|
769
|
+
# Compile
|
|
770
|
+
# --------------------------------------------------------------------------
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _validation_message(cls_name: str, e: ValidationError) -> str:
|
|
774
|
+
parts = []
|
|
775
|
+
seen = set()
|
|
776
|
+
for err in e.errors():
|
|
777
|
+
# a field typed `Literal[...] | int` reports one error per branch: keep the first
|
|
778
|
+
loc = ".".join(str(x) for x in err.get("loc", ()) if not str(x).startswith(("literal[", "int", "str")))
|
|
779
|
+
if loc in seen:
|
|
780
|
+
continue
|
|
781
|
+
seen.add(loc)
|
|
782
|
+
msg = err.get("msg", "invalid value")
|
|
783
|
+
if err.get("type") == "extra_forbidden":
|
|
784
|
+
msg = "unknown field"
|
|
785
|
+
parts.append("%s: %s" % (loc, msg) if loc else msg)
|
|
786
|
+
return "%s(): %s" % (cls_name, "; ".join(parts))
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _eval(node: ast.AST) -> Any:
|
|
790
|
+
"""Evaluate a literal database value: constants, lists, model calls."""
|
|
791
|
+
if isinstance(node, ast.Constant):
|
|
792
|
+
if node.value is None or isinstance(node.value, (bool, int, str)):
|
|
793
|
+
return node.value
|
|
794
|
+
raise CompileError("unsupported value %r" % (node.value,), node)
|
|
795
|
+
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)):
|
|
796
|
+
v = _eval(node.operand)
|
|
797
|
+
if isinstance(v, bool) or not isinstance(v, int):
|
|
798
|
+
raise CompileError("unary minus needs a number", node)
|
|
799
|
+
return -v if isinstance(node.op, ast.USub) else v
|
|
800
|
+
if isinstance(node, (ast.List, ast.Tuple)):
|
|
801
|
+
out = []
|
|
802
|
+
for e in node.elts:
|
|
803
|
+
if isinstance(e, ast.Starred):
|
|
804
|
+
raise CompileError("*unpacking is not supported", e)
|
|
805
|
+
out.append(_eval(e))
|
|
806
|
+
return out
|
|
807
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Mult):
|
|
808
|
+
a, b = _eval(node.left), _eval(node.right)
|
|
809
|
+
if isinstance(a, int) and isinstance(b, list):
|
|
810
|
+
a, b = b, a
|
|
811
|
+
if not isinstance(a, list) or isinstance(b, bool) or not isinstance(b, int) or b < 0:
|
|
812
|
+
raise CompileError("only `[...] * count` is supported", node)
|
|
813
|
+
copies = [_copier(x) for x in a]
|
|
814
|
+
return [c() for _ in range(b) for c in copies]
|
|
815
|
+
if isinstance(node, ast.Call):
|
|
816
|
+
if not isinstance(node.func, ast.Name) or node.func.id not in MODEL_CLASSES:
|
|
817
|
+
raise CompileError(
|
|
818
|
+
"unknown call %s(); expected one of the rpgsync.db models" % ast.unparse(node.func), node
|
|
819
|
+
)
|
|
820
|
+
cls = MODEL_CLASSES[node.func.id]
|
|
821
|
+
positional = [f for f in ("id", "name") if f in cls.model_fields]
|
|
822
|
+
if len(node.args) > len(positional) or any(isinstance(a, ast.Starred) for a in node.args):
|
|
823
|
+
raise CompileError(
|
|
824
|
+
"%s(): only %s can be given without a keyword" % (node.func.id, " and ".join(positional) or "nothing"),
|
|
825
|
+
node,
|
|
826
|
+
)
|
|
827
|
+
kw = {f: _eval(a) for f, a in zip(positional, node.args)}
|
|
828
|
+
for k in node.keywords:
|
|
829
|
+
if k.arg is None:
|
|
830
|
+
raise CompileError("%s(): **kwargs is not supported" % node.func.id, node)
|
|
831
|
+
if k.arg in kw:
|
|
832
|
+
raise CompileError("%s(): %s given twice" % (node.func.id, k.arg), k.value)
|
|
833
|
+
kw[k.arg] = _eval(k.value)
|
|
834
|
+
try:
|
|
835
|
+
return cls(**kw)
|
|
836
|
+
except ValidationError as e:
|
|
837
|
+
raise CompileError(_validation_message(node.func.id, e), node) from None
|
|
838
|
+
if isinstance(node, ast.Name):
|
|
839
|
+
raise CompileError("unknown name %r (only literal values are allowed)" % node.id, node)
|
|
840
|
+
raise CompileError("unsupported expression %s" % ast.unparse(node), node)
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
_PLAIN = (type(None), bool, int, str)
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _copier(v: Any) -> Callable[[], Any]:
|
|
847
|
+
"""What copies an evaluated value, for `[...] * count`: a model holding plain values
|
|
848
|
+
only (the 440 `BattlerAnimationItemSkill()` of a skill) is copied shallow, much
|
|
849
|
+
faster than deepcopy."""
|
|
850
|
+
if type(v) in _PLAIN:
|
|
851
|
+
return lambda: v
|
|
852
|
+
if isinstance(v, BaseModel) and all(type(x) in _PLAIN for x in v.__dict__.values()):
|
|
853
|
+
return v.model_copy
|
|
854
|
+
return lambda: copy.deepcopy(v)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
@cache
|
|
858
|
+
def _plan(sname: str) -> tuple[tuple[FieldDef, str, str, bool], ...]:
|
|
859
|
+
"""(field, kind, enum or structure, depends on the engine) of the exported fields."""
|
|
860
|
+
return tuple((f, *f.model.partition(":")[::2], _engine_dependent(f)) for f in _fields(sname))
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
@cache
|
|
864
|
+
def _enum_names(enum: str) -> dict[int, str]:
|
|
865
|
+
names: dict[int, str] = {}
|
|
866
|
+
for k, x in ENUMS[enum].items():
|
|
867
|
+
names.setdefault(x, k)
|
|
868
|
+
return names
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _normalize(model: BaseModel, sname: str) -> None:
|
|
872
|
+
"""Enum numbers -> names, element ids -> explicit positions."""
|
|
873
|
+
for f, kind, arg, _ in _plan(sname):
|
|
874
|
+
if kind == "enum":
|
|
875
|
+
v = getattr(model, f.name)
|
|
876
|
+
if isinstance(v, int) and not isinstance(v, bool):
|
|
877
|
+
setattr(model, f.name, _enum_names(arg).get(v, v))
|
|
878
|
+
elif kind == "struct":
|
|
879
|
+
_normalize(getattr(model, f.name), arg)
|
|
880
|
+
elif kind == "array":
|
|
881
|
+
for i, e in enumerate(getattr(model, f.name)):
|
|
882
|
+
if e.id is None:
|
|
883
|
+
e.id = i + 1
|
|
884
|
+
_normalize(e, arg)
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _check_encodable(model: BaseModel, sname: str, ctx: Ctx, node: ast.AST) -> None:
|
|
888
|
+
"""Fail at compile time for values that cannot be stored (text outside
|
|
889
|
+
the game encoding, numbers out of range...)."""
|
|
890
|
+
for f, kind, arg, engine_dependent in _plan(sname):
|
|
891
|
+
v = getattr(model, f.name)
|
|
892
|
+
try:
|
|
893
|
+
if kind == "struct":
|
|
894
|
+
_check_encodable(v, arg, ctx, node)
|
|
895
|
+
elif kind == "array":
|
|
896
|
+
checked = set() # the values of elements already checked (440 alike in a skill)
|
|
897
|
+
for e in v:
|
|
898
|
+
_check_range(e.id, *INT32, "%s id" % f.name)
|
|
899
|
+
values = tuple(e.__dict__.values())
|
|
900
|
+
try:
|
|
901
|
+
if values[1:] in checked: # all but the id (the first field)
|
|
902
|
+
continue
|
|
903
|
+
checked.add(values[1:])
|
|
904
|
+
except TypeError: # unhashable: lists, structures
|
|
905
|
+
pass
|
|
906
|
+
_check_encodable(e, arg, ctx, node)
|
|
907
|
+
elif not (engine_dependent and v is None):
|
|
908
|
+
_to_lcf(f, v, ctx)
|
|
909
|
+
except CompileError as e:
|
|
910
|
+
raise CompileError("%s: %s" % (f.name, e), node) from None
|
|
911
|
+
except (ValueError, LcfError) as e:
|
|
912
|
+
raise CompileError("%s: %s" % (type(model).__name__, e), node) from None
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def _table_body(name: str, tree: ast.Module) -> tuple[list[ast.stmt], ast.List | None]:
|
|
916
|
+
"""Statements holding the entries: the class body (or, in files written
|
|
917
|
+
by older rpgsync versions, the top level with a `name = [...]` list)."""
|
|
918
|
+
classes = [st for st in tree.body if isinstance(st, ast.ClassDef)]
|
|
919
|
+
if len(classes) > 1:
|
|
920
|
+
raise CompileError("a database file holds one class", classes[1])
|
|
921
|
+
legacy = None
|
|
922
|
+
for stmt in tree.body:
|
|
923
|
+
if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant) and isinstance(stmt.value.value, str):
|
|
924
|
+
continue
|
|
925
|
+
if isinstance(stmt, (ast.Import, ast.ImportFrom, ast.ClassDef)) or size_stmt(stmt) is not None:
|
|
926
|
+
continue
|
|
927
|
+
if isinstance(stmt, ast.Assign) and len(stmt.targets) == 1 and isinstance(stmt.targets[0], ast.Name):
|
|
928
|
+
v = stmt.value
|
|
929
|
+
if classes and isinstance(v, ast.Call) and isinstance(v.func, ast.Name) and v.func.id == classes[0].name:
|
|
930
|
+
continue # `variables = Variables()`
|
|
931
|
+
if not classes and stmt.targets[0].id == name and isinstance(v, ast.List):
|
|
932
|
+
legacy = v
|
|
933
|
+
continue
|
|
934
|
+
cls_name = table_class(name)[0]
|
|
935
|
+
raise CompileError(
|
|
936
|
+
"top level may only contain `class %s(...)` and `%s = %s()`" % (cls_name, name, cls_name), stmt
|
|
937
|
+
)
|
|
938
|
+
if classes:
|
|
939
|
+
return classes[0].body, None
|
|
940
|
+
if legacy is None:
|
|
941
|
+
# not an empty table (that is `class X(Table): pass`): a truncated or
|
|
942
|
+
# emptied file must not wipe the whole table
|
|
943
|
+
cls_name, base = table_class(name)
|
|
944
|
+
raise CompileError("missing `class %s(%s):` holding the entries" % (cls_name, base))
|
|
945
|
+
return [st for st in tree.body if size_stmt(st) is not None], legacy
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def table_size(text: str) -> TableSize | None:
|
|
949
|
+
"""The ``size = N`` statement of a database file (checked by compile_table)."""
|
|
950
|
+
tree = ast.parse(text)
|
|
951
|
+
classes = [st for st in tree.body if isinstance(st, ast.ClassDef)]
|
|
952
|
+
for stmt in classes[0].body if classes else tree.body:
|
|
953
|
+
size = size_stmt(stmt)
|
|
954
|
+
if size is not None:
|
|
955
|
+
return size
|
|
956
|
+
return None
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def compile_table(name: str, text: str, ctx: Ctx, filename: str = "<database>") -> list[M.DbModel]:
|
|
960
|
+
"""Parse a database file into its entries (validated models, with their
|
|
961
|
+
attribute name in ``_ident``). ``size = N`` is read by table_size()."""
|
|
962
|
+
return compile_table_sized(name, text, ctx, filename)[0]
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def compile_table_sized(
|
|
966
|
+
name: str, text: str, ctx: Ctx, filename: str = "<database>"
|
|
967
|
+
) -> tuple[list[M.DbModel], TableSize | None]:
|
|
968
|
+
"""compile_table() and table_size() in one parse. -> (entries, size)"""
|
|
969
|
+
if name not in _TABLE_INFO:
|
|
970
|
+
raise CompileError("unknown database table %r" % name)
|
|
971
|
+
sname = table_struct(name)
|
|
972
|
+
cls = MODEL_CLASSES[sname]
|
|
973
|
+
try:
|
|
974
|
+
tree = ast.parse(text, filename)
|
|
975
|
+
except SyntaxError as e:
|
|
976
|
+
raise CompileError("syntax error: %s" % e.msg, e) from None
|
|
977
|
+
body, legacy = _table_body(name, tree)
|
|
978
|
+
items: list[tuple[str | None, ast.expr]] = []
|
|
979
|
+
seen_size = False
|
|
980
|
+
size = None
|
|
981
|
+
names: dict[str, int] = {}
|
|
982
|
+
for stmt in body:
|
|
983
|
+
if isinstance(stmt, ast.Pass) or (
|
|
984
|
+
isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant) and isinstance(stmt.value.value, str)
|
|
985
|
+
):
|
|
986
|
+
continue
|
|
987
|
+
found = size_stmt(stmt)
|
|
988
|
+
if found is not None:
|
|
989
|
+
if seen_size:
|
|
990
|
+
raise CompileError("size is assigned twice", stmt)
|
|
991
|
+
seen_size = True
|
|
992
|
+
size = found
|
|
993
|
+
continue
|
|
994
|
+
target = None
|
|
995
|
+
if isinstance(stmt, ast.Assign) and len(stmt.targets) == 1:
|
|
996
|
+
target = stmt.targets[0]
|
|
997
|
+
elif isinstance(stmt, ast.AnnAssign) and stmt.value is not None:
|
|
998
|
+
target = stmt.target
|
|
999
|
+
if not isinstance(target, ast.Name) or stmt.value is None: # type: ignore[union-attr]
|
|
1000
|
+
raise CompileError("a database class may only contain `size = N` and `name = %s(...)`" % sname, stmt)
|
|
1001
|
+
if target.id in names:
|
|
1002
|
+
raise CompileError("%s is defined twice (also on line %d)" % (target.id, names[target.id]), stmt)
|
|
1003
|
+
names[target.id] = stmt.lineno
|
|
1004
|
+
items.append((target.id, stmt.value)) # type: ignore[union-attr]
|
|
1005
|
+
if legacy is not None:
|
|
1006
|
+
items += [(None, node) for node in legacy.elts]
|
|
1007
|
+
entries = []
|
|
1008
|
+
seen: dict[int, int] = {}
|
|
1009
|
+
for ident, node in items:
|
|
1010
|
+
entry = _eval(node)
|
|
1011
|
+
if not isinstance(entry, cls):
|
|
1012
|
+
raise CompileError("%s entries must be %s(...)" % (name, sname), node)
|
|
1013
|
+
if entry.id is not None and (isinstance(entry.id, bool) or entry.id < 1):
|
|
1014
|
+
raise CompileError("%s(): id must be a positive integer" % sname, node)
|
|
1015
|
+
if entry.id is not None and entry.id in seen:
|
|
1016
|
+
raise CompileError("%s id %d is used twice (also on line %d)" % (sname, entry.id, seen[entry.id]), node)
|
|
1017
|
+
if entry.id is not None:
|
|
1018
|
+
seen[entry.id] = node.lineno
|
|
1019
|
+
_normalize(entry, sname)
|
|
1020
|
+
_check_limits(entry, sname, ctx.engine, node)
|
|
1021
|
+
_check_encodable(entry, sname, ctx, node)
|
|
1022
|
+
entry._lineno = node.lineno
|
|
1023
|
+
entry._ident = ident
|
|
1024
|
+
entries.append(entry)
|
|
1025
|
+
return entries, size
|