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/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