rpgsync 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. rpgsync-0.2.0/CHANGELOG.md +95 -0
  2. {rpgsync-0.1.0 → rpgsync-0.2.0}/PKG-INFO +4 -7
  3. {rpgsync-0.1.0 → rpgsync-0.2.0}/README.md +3 -6
  4. {rpgsync-0.1.0 → rpgsync-0.2.0}/pyproject.toml +1 -1
  5. {rpgsync-0.1.0 → rpgsync-0.2.0}/pyproject.toml.orig +11 -9
  6. {rpgsync-0.1.0 → rpgsync-0.2.0}/rpgsync/cli.py +69 -20
  7. {rpgsync-0.1.0 → rpgsync-0.2.0}/rpgsync/commands.py +142 -18
  8. {rpgsync-0.1.0 → rpgsync-0.2.0}/rpgsync/compat.py +9 -1
  9. {rpgsync-0.1.0 → rpgsync-0.2.0}/rpgsync/database.py +166 -4
  10. {rpgsync-0.1.0 → rpgsync-0.2.0}/rpgsync/db.py +768 -1
  11. {rpgsync-0.1.0 → rpgsync-0.2.0}/rpgsync/dbschema.py +74 -40
  12. {rpgsync-0.1.0 → rpgsync-0.2.0}/rpgsync/dsl.py +482 -36
  13. {rpgsync-0.1.0 → rpgsync-0.2.0}/rpgsync/dynparams.py +3 -0
  14. {rpgsync-0.1.0 → rpgsync-0.2.0}/rpgsync/dynrpg.py +70 -2
  15. rpgsync-0.2.0/rpgsync/layout.py +241 -0
  16. {rpgsync-0.1.0 → rpgsync-0.2.0}/rpgsync/lcf.py +15 -12
  17. rpgsync-0.2.0/rpgsync/maniac.py +1195 -0
  18. rpgsync-0.2.0/rpgsync/maniac_ext.py +367 -0
  19. {rpgsync-0.1.0 → rpgsync-0.2.0}/rpgsync/named.py +21 -1
  20. {rpgsync-0.1.0 → rpgsync-0.2.0}/rpgsync/project.py +22 -1
  21. {rpgsync-0.1.0 → rpgsync-0.2.0}/rpgsync/pyexpr.py +463 -23
  22. {rpgsync-0.1.0 → rpgsync-0.2.0}/rpgsync/resources.py +53 -10
  23. {rpgsync-0.1.0 → rpgsync-0.2.0}/rpgsync/scaffold.py +26 -3
  24. {rpgsync-0.1.0 → rpgsync-0.2.0}/rpgsync/script.py +150 -34
  25. {rpgsync-0.1.0 → rpgsync-0.2.0}/rpgsync/sync.py +77 -4
  26. {rpgsync-0.1.0 → rpgsync-0.2.0}/rpgsync/tables.py +27 -5
  27. rpgsync-0.1.0/CHANGELOG.md +0 -29
  28. rpgsync-0.1.0/rpgsync/maniac.py +0 -387
  29. {rpgsync-0.1.0 → rpgsync-0.2.0}/LICENSE +0 -0
  30. {rpgsync-0.1.0 → rpgsync-0.2.0}/rpgsync/__init__.py +0 -0
  31. {rpgsync-0.1.0 → rpgsync-0.2.0}/rpgsync/__main__.py +0 -0
  32. {rpgsync-0.1.0 → rpgsync-0.2.0}/rpgsync/fmt.py +0 -0
  33. {rpgsync-0.1.0 → rpgsync-0.2.0}/rpgsync/maptree.py +0 -0
  34. {rpgsync-0.1.0 → rpgsync-0.2.0}/rpgsync/messages.py +0 -0
  35. {rpgsync-0.1.0 → rpgsync-0.2.0}/rpgsync/py.typed +0 -0
  36. {rpgsync-0.1.0 → rpgsync-0.2.0}/rpgsync/scriptfiles.py +0 -0
  37. {rpgsync-0.1.0 → rpgsync-0.2.0}/rpgsync/venv.py +0 -0
@@ -0,0 +1,95 @@
1
+ # Changelog
2
+
3
+ ## Versions
4
+
5
+ rpgsync is released on [PyPI](https://pypi.org/project/rpgsync/) as `0.MINOR.PATCH`:
6
+
7
+ - **MINOR** goes up when the scripts change: `rpgsync pull` writes a game's scripts
8
+ differently (another layout, other names, another way to write a command), or scripts
9
+ written by it may not work with the previous minor version (a DSL name renamed or
10
+ removed).
11
+ - **PATCH** goes up for everything else: fixes and additions after which `rpgsync pull`
12
+ writes the same scripts.
13
+
14
+ A game's `Scripts/pyproject.toml` depends on the version that set it up,
15
+ `rpgsync~=0.MINOR.PATCH` (that version and its later patches), and its `Scripts/uv.lock`
16
+ pins the exact one. [Upgrading rpgsync](README.md#upgrading-rpgsync) moves a game to a
17
+ newer one; [Releasing](docs/development.md#releasing) makes a new one.
18
+
19
+ ## 0.2.0
20
+
21
+ The scripts of Maniac Patch games change: `rpgsync pull` writes them differently.
22
+
23
+ - Maniac Patch commands have typed, named forms: `maniac.show_string_picture(...)`,
24
+ `maniac.console_print(...)` & co, `maniac.get_media_option(...)` /
25
+ `set_media_option(...)`, `maniac.get_game_info("map_size", into=...)`, and
26
+ `maniac.script("line 1", "line 2", results=[...])` holding a script and its further
27
+ lines in one call. `write_picture` with all its parameters zero is no longer raw.
28
+ - String variables: `database/strings.py` names them, and Control Strings are Python
29
+ statements on `strings` (`strings.name = "Alex"`, `+=`, `str(variables.n).rjust(4, "0")`,
30
+ `db_name("item", 3)`, `read_file(...)`, `.replace()`, `int()`, `len()`, `.find()`,
31
+ `.split(into=...)`, `.pop_line()`, `.to_file()`), and Conditional Branch compares them
32
+ (`if strings.name == "Alex":`, `"x" in strings[1]`).
33
+ - Maniac options of standard commands are Python: Control Variables operands and
34
+ operations (`variables.x = variables.a * 2`, `sqrt()`, `sin()`, `min()`, `abs()`,
35
+ `lerp()`, `array_sum()`, `party[0].hp`, `game.frames`, `switches[n]`, `a if b > c else d`,
36
+ `variables[10:1:-1]` backwards ranges, `variables[variables.a:variables.b + 1]`), loops
37
+ (`for variables.i in range(1, 11):`, `while variables.hp > 0:`, `continue`,
38
+ `break_loop(levels=2)`), `wait(frames=...)`, `common_events[variables.n]()`,
39
+ `events[n].erase()` / `.restore()`, names and values from variables in `play_bgm()`,
40
+ `play_se()`, `face()`, `message_options(width=..., font=...)`, pixel scrolling
41
+ (`pan_screen_by()`, `pan_screen_to()`), `call_system_function()`,
42
+ `disable_system_functions()`.
43
+ - The Maniac Patch's editor stores some standard commands with other parameters (a
44
+ Control Variables of one variable with 0 as last id, zero parameters more on loops,
45
+ faces without their trailing zeros...): rpgsync detects the layout most of a game
46
+ uses, writes the same Python for both, and marks a command stored the other way
47
+ with `# layout: maniac` / `# layout: rpg_maker`. A 2003 game's Wait without its key
48
+ parameter is `wait(1.0, key=None)` instead of a raw line.
49
+ - `database/terms.py` and `database/system.py` export the Terms and the System settings
50
+ (generated from liblcf's schema: a field named there appears once rpgsync's schema
51
+ is generated again; chunks it doesn't know are kept). The Maniac Patch's System
52
+ fields are named: title and save scene hooks, message window font, size, spacing and
53
+ face layout. Skill `failure_message` 3 (Maniac's Hit Rate Calc) and `-9` in
54
+ `menu_commands` (Game End hidden) are documented.
55
+ - Common events run on `"battle_start"` and `"battle_parallel"` (Maniac Patch triggers
56
+ 6 and 7).
57
+ - `rpgsync check`: in a game declaring `"maniac"`, `database/game_types.py` lets
58
+ `variables[n]` stand where the patch reads a value (`randint(1, variables.max)`);
59
+ without it, Maniac options of standard commands are reported as warnings.
60
+ - `tools/gen_dbschema.py` reads the EasyRPG Editor's liblcf (`../Editor/lib/liblcf`) by
61
+ default.
62
+
63
+ ## 0.1.2
64
+
65
+ - `rpgsync check --resources` names every file in its warnings, instead of the first 8 and
66
+ how many more: the files the database's entries use, sorted, with their number, and
67
+ the imported files never used. A list too long for its line goes under it, wrapped to
68
+ the terminal's width without cutting a name. A file used several times in one game file
69
+ (`Map0003.lmu`) is no longer named again in "also used at".
70
+ - `rpgsync check --resources` reads a name DynParams builds for a command
71
+ (`pictures[2].show(f"w{variables.sheet}")`) whole, in that command's folder, numbers
72
+ from variables as digits: `Picture/w4.png` counts as used, as a name of one letter
73
+ then a number is no longer too short to tell, and `Picture/wall.png` doesn't. The name
74
+ of a command whose parameters alone DynParams rewrites is checked like any other.
75
+
76
+ ## 0.1.1
77
+
78
+ - `rpgsync check`: the pages that one later page keeps from ever being active are
79
+ named in one warning, in fewer words.
80
+ - `rpgsync check` names files relative to the current folder (`Scripts/Map0004.py:12`
81
+ run from the game folder), ty's problems too, so terminals and editors can open them.
82
+ - `rpgsync check --resources`: a name ending with a space (`"Theme "`, whose file is
83
+ `Theme .mp3`) is found, instead of reported as not imported and its file as never used.
84
+ - rpgsync knows CortiXCustomCrit.dll (Corti's critical hits): its `dyn.ccrit_*`
85
+ commands, and its `[CortiCustomCrit]` and `[CortiLog]` sections of `DynRPG.ini`.
86
+
87
+ ## 0.1.0
88
+
89
+ First release on PyPI.
90
+
91
+ - A new game's scripts depend on `rpgsync~=0.1.0` from PyPI, pinned by their `uv.lock`,
92
+ instead of a link to this computer's rpgsync folder.
93
+ - A developer of rpgsync links their rpgsync folder as `Scripts/.rpgsync/rpgsync`:
94
+ rpgsync installs it, editable, in the scripts' environment, and leaves
95
+ `pyproject.toml` and `uv.lock` as they are.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rpgsync
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: A Python twin of your RPG Maker 2000/2003 game
5
5
  Keywords: rpg-maker,rpg-maker-2000,rpg-maker-2003,easyrpg,gamedev,dsl
6
6
  Author: Nicolas Oulianov
@@ -40,8 +40,9 @@ A Python twin of your RPG Maker 2000/2003 game.
40
40
  - Implement automated tests to verify game mechanics
41
41
  - Improve your game using the modern developer's toolkit
42
42
 
43
+ <img width="1920" height="754" alt="rpgsync" src="https://github.com/user-attachments/assets/11a3fc5a-7884-4c7c-9aef-ee9ee54aa4bc" />
43
44
 
44
- ## Code example
45
+ ## Example
45
46
 
46
47
  ```python
47
48
  @event(10, "Jeremy", x=15, y=38)
@@ -81,11 +82,7 @@ class Ulysse(Event): # The other event!
81
82
  pass
82
83
  ```
83
84
 
84
- Every command, option and value is typed and documented in
85
- [`rpgsync/dsl.py`](https://github.com/oulianov/rpgsync/blob/master/rpgsync/dsl.py): your
86
- editor shows the docs on hover and flags wrong values (a misspelled direction, a sprite
87
- index above 7, ...). [Writing scripts](https://github.com/oulianov/rpgsync/blob/master/docs/scripts.md)
88
- explains events and pages, variables by name, the database and the map tree.
85
+ Every command, option and value is typed and documented. The database, the map tree, the variable names, battle command events are also transformer. [Learn more here.](https://github.com/oulianov/rpgsync/blob/master/docs/scripts.md)
89
86
 
90
87
  ## Demo
91
88
 
@@ -9,8 +9,9 @@ A Python twin of your RPG Maker 2000/2003 game.
9
9
  - Implement automated tests to verify game mechanics
10
10
  - Improve your game using the modern developer's toolkit
11
11
 
12
+ <img width="1920" height="754" alt="rpgsync" src="https://github.com/user-attachments/assets/11a3fc5a-7884-4c7c-9aef-ee9ee54aa4bc" />
12
13
 
13
- ## Code example
14
+ ## Example
14
15
 
15
16
  ```python
16
17
  @event(10, "Jeremy", x=15, y=38)
@@ -50,11 +51,7 @@ class Ulysse(Event): # The other event!
50
51
  pass
51
52
  ```
52
53
 
53
- Every command, option and value is typed and documented in
54
- [`rpgsync/dsl.py`](https://github.com/oulianov/rpgsync/blob/master/rpgsync/dsl.py): your
55
- editor shows the docs on hover and flags wrong values (a misspelled direction, a sprite
56
- index above 7, ...). [Writing scripts](https://github.com/oulianov/rpgsync/blob/master/docs/scripts.md)
57
- explains events and pages, variables by name, the database and the map tree.
54
+ Every command, option and value is typed and documented. The database, the map tree, the variable names, battle command events are also transformer. [Learn more here.](https://github.com/oulianov/rpgsync/blob/master/docs/scripts.md)
58
55
 
59
56
  ## Demo
60
57
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rpgsync"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "A Python twin of your RPG Maker 2000/2003 game"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rpgsync"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "A Python twin of your RPG Maker 2000/2003 game"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -8,7 +8,14 @@ license = "MIT"
8
8
  # through a link (the older scripts' .rpgsync/rpgsync source), and the scripts can't
9
9
  # install rpgsync. LICENSE goes in the sdist through source-include below.
10
10
  authors = [{ name = "Nicolas Oulianov" }]
11
- keywords = ["rpg-maker", "rpg-maker-2000", "rpg-maker-2003", "easyrpg", "gamedev", "dsl"]
11
+ keywords = [
12
+ "rpg-maker",
13
+ "rpg-maker-2000",
14
+ "rpg-maker-2003",
15
+ "easyrpg",
16
+ "gamedev",
17
+ "dsl",
18
+ ]
12
19
  classifiers = [
13
20
  "Development Status :: 3 - Alpha",
14
21
  "Environment :: Console",
@@ -51,15 +58,10 @@ source-include = ["LICENSE", "CHANGELOG.md"]
51
58
 
52
59
  [tool.pytest.ini_options]
53
60
  testpaths = ["tests"]
54
- addopts = "-n auto" # pytest-xdist: one worker per CPU
61
+ addopts = "-n auto" # pytest-xdist: one worker per CPU
55
62
 
56
63
  [dependency-groups]
57
- dev = [
58
- "pytest>=9.1.1",
59
- "pytest-xdist>=3.8.0",
60
- "ruff>=0.16.10",
61
- "ty>=0.0.85",
62
- ]
64
+ dev = ["pytest>=9.1.1", "pytest-xdist>=3.8.0", "ruff>=0.16.10", "ty>=0.0.85"]
63
65
 
64
66
  [tool.ruff]
65
67
  line-length = 120
@@ -429,7 +429,14 @@ def _run(syncer: CliSyncer, units, prefer: str | None, initial: bool = False, fo
429
429
 
430
430
  Part = tuple[str, str] # (text, rich style)
431
431
 
432
- PLURAL = {"event": "events", "common event": "common events", "troop": "troops", "map": "maps", "entry": "entries"}
432
+ PLURAL = {
433
+ "event": "events",
434
+ "common event": "common events",
435
+ "troop": "troops",
436
+ "map": "maps",
437
+ "entry": "entries",
438
+ "field": "fields",
439
+ }
433
440
 
434
441
 
435
442
  def _ids(ids: list[int], limit: int = 8) -> str:
@@ -460,7 +467,9 @@ def _entry_diff(old: list, new: list, what: str) -> list[Part]:
460
467
  ("removed", sorted(i for i in a.keys() - b.keys() if i > 0), "red"),
461
468
  ):
462
469
  if ids:
463
- parts.append(("%s %s %s" % (PLURAL[what] if len(ids) > 1 else what, _ids(ids), label), style))
470
+ named = [getattr(b.get(i) or a.get(i), "name", None) for i in ids] if what == "field" else []
471
+ shown = ", ".join(named) if named and all(named) else _ids(ids) # terms/system: the field names
472
+ parts.append(("%s %s %s" % (PLURAL[what] if len(ids) > 1 else what, shown, label), style))
464
473
  if isinstance(pa, TreeOrder) and isinstance(pb, TreeOrder):
465
474
  both = set(pa.order) & set(pb.order) # maps added or removed are listed above
466
475
  if [m for m in pa.order if m in both] != [m for m in pb.order if m in both]:
@@ -536,7 +545,9 @@ def _status(syncer: Syncer, units: list[Unit]) -> dict[str, list[StatusLine]]:
536
545
  data = data_of[u.bin_path]
537
546
  if data is None:
538
547
  continue
539
- what = {"map": "event", "common": "common event", "troop": "troop", "maptree": "map"}.get(u.kind, "entry")
548
+ what = u.what or {"map": "event", "common": "common event", "troop": "troop", "maptree": "map"}.get(
549
+ u.kind, "entry"
550
+ )
540
551
  py_name = u.script_label(project.script_dir)
541
552
  bin_name = os.path.relpath(u.bin_path, project.game_dir)
542
553
  if u.kind == "database":
@@ -923,7 +934,7 @@ def check(
923
934
  for found, part in _checked_units(proj, units, rules, resources, jobs):
924
935
  for kind, where, problem in found:
925
936
  color = "red" if kind == "error" else "yellow"
926
- err.print(*([(where, "cyan"), ": "] if where else []), (problem, color), kind=kind)
937
+ err.print(*([(_here(where), "cyan"), ": "] if where else []), (problem, color), kind=kind)
927
938
  errors += kind == "error"
928
939
  warnings += kind == "warning"
929
940
  if files is not None and part is not None:
@@ -1125,7 +1136,7 @@ def _check_resources(resources: Collector, proj: Project, ui: UI, err: UI, compl
1125
1136
  grouped: dict[tuple[str, str], list] = {} # (database file, folder) -> its references
1126
1137
  uses: dict[tuple[str, str], list] = {} # (folder, file) -> the events using it
1127
1138
  for ref in found.missing:
1128
- if not ref.event: # the database keeps many (default entries never shown): one line per folder
1139
+ if not ref.event: # the database keeps many (default entries never shown): one warning per folder
1129
1140
  grouped.setdefault((ref.path, ref.folder), []).append(ref)
1130
1141
  else: # one line per file, at its first use
1131
1142
  uses.setdefault((ref.folder, resource_key(ref.name)), []).append(ref)
@@ -1133,30 +1144,51 @@ def _check_resources(resources: Collector, proj: Project, ui: UI, err: UI, compl
1133
1144
  ref = refs[0]
1134
1145
  where = "%s:%d" % (ref.path, ref.line) if ref.line else ref.path
1135
1146
  problem = "%s uses %s/%s, which is not imported" % (ref.what, ref.folder, ref.name)
1136
- if len(refs) > 1:
1137
- others = [
1138
- "%s:%d" % (os.path.basename(r.path), r.line) if r.line else os.path.basename(r.path) for r in refs[1:4]
1139
- ]
1140
- more = " and %d more" % (len(refs) - 4) if len(refs) > 4 else ""
1141
- problem += " (also used at %s%s)" % (", ".join(others), more)
1142
- err.print((where, "cyan"), ": ", (problem, "yellow"), kind="warning")
1147
+ # its other places, each once: a game file (Map0003.lmu) has no lines
1148
+ others = list(dict.fromkeys(os.path.basename(r.path) + (":%d" % r.line if r.line else "") for r in refs[1:]))
1149
+ others = [o for o in others if o != os.path.basename(where)]
1150
+ if others:
1151
+ more = " and %d more" % (len(others) - 3) if len(others) > 3 else ""
1152
+ problem += " (also used at %s%s)" % (", ".join(others[:3]), more)
1153
+ err.print((_here(where), "cyan"), ": ", (problem, "yellow"), kind="warning")
1143
1154
  for (path, folder), refs in grouped.items():
1144
- where = "%s:%d" % (path, refs[0].line) if refs[0].line else path
1145
- names = list(dict.fromkeys(r.name for r in refs))
1146
- shown = ", ".join(names[:8]) + (" and %d more" % (len(names) - 8) if len(names) > 8 else "")
1155
+ where = _here("%s:%d" % (path, refs[0].line) if refs[0].line else path)
1147
1156
  if len(refs) == 1:
1148
1157
  problem = "%s uses %s/%s, which is not imported" % (refs[0].what, folder, refs[0].name)
1149
- else:
1150
- problem = "%d entries use %s files that are not imported: %s" % (len(refs), folder, shown)
1151
- err.print((where, "cyan"), ": ", (problem, "yellow"), kind="warning")
1158
+ err.print((where, "cyan"), ": ", (problem, "yellow"), kind="warning")
1159
+ continue
1160
+ names = sorted(dict.fromkeys(r.name for r in refs), key=str.casefold)
1161
+ files = "%d %s file%s" % (len(names), folder, "s that are" if len(names) > 1 else " that is")
1162
+ _print_list(err, where, "%d entries use %s not imported" % (len(refs), files), names)
1152
1163
  for folder, names in found.unused.items():
1153
- problem = "%d imported file%s never used: %s" % (len(names), "s" * (len(names) > 1), ", ".join(names))
1154
- err.print((folder + "/", "cyan"), ": ", (problem, "yellow"), kind="warning")
1164
+ problem = "%d imported file%s never used" % (len(names), "s" * (len(names) > 1))
1165
+ _print_list(err, folder + "/", problem, names)
1155
1166
  if found.note:
1156
1167
  ui.print(found.note, kind="info")
1157
1168
  return len(uses) + len(grouped) + len(found.unused)
1158
1169
 
1159
1170
 
1171
+ def _print_list(err: UI, where: str, problem: str, names: list[str]) -> None:
1172
+ """A warning about files, all of them named: on its line when they fit, else under it,
1173
+ in indented lines as wide as the terminal, a name never cut in two."""
1174
+ listed = ", ".join(names)
1175
+ width = err.console.width
1176
+ if len(err.marker("warning")) + len("%s: %s: %s" % (where, problem, listed)) <= width:
1177
+ err.print((where, "cyan"), ": ", ("%s: %s" % (problem, listed), "yellow"), kind="warning")
1178
+ return
1179
+ err.print((where, "cyan"), ": ", (problem + ":", "yellow"), kind="warning")
1180
+ indent = " "
1181
+ row = ""
1182
+ for i, name in enumerate(names):
1183
+ item = name + ("," if i < len(names) - 1 else "")
1184
+ if row and len(indent) + len(row) + 1 + len(item) > width:
1185
+ err.print((indent + row, "yellow"))
1186
+ row = item
1187
+ else:
1188
+ row = row + " " + item if row else item
1189
+ err.print((indent + row, "yellow"))
1190
+
1191
+
1160
1192
  def _ty(script_dir: str) -> str | None:
1161
1193
  """The ty of the scripts folder's environment, else one on the PATH."""
1162
1194
  for rel in (".venv/bin/ty", ".venv/Scripts/ty.exe"):
@@ -1183,10 +1215,27 @@ def _check_types(script_dir: str, ui: UI, err: UI, paths: list[Path] | None = No
1183
1215
  if result.returncode == 0 or not lines:
1184
1216
  return 0
1185
1217
  for line in lines:
1218
+ m = re.match(r"([^:\s][^:]*\.pyi?)(:\d+:\d+: .*)$", line) # ty's paths: relative to the scripts folder
1219
+ if m:
1220
+ line = _here(os.path.join(script_dir, m.group(1))) + m.group(2)
1186
1221
  err.print((line, "red"), kind="error")
1187
1222
  return max(len(lines), 1)
1188
1223
 
1189
1224
 
1225
+ def _here(where: str) -> str:
1226
+ """A place `check` names (a path, path:line, or a file of the game: RPG_RT.lmt), with
1227
+ its path relative to the current folder when it is in it: Scripts/Map0004.py:12, run
1228
+ from the game folder."""
1229
+ m = re.match(r"(.+?)((?::\d+)*)$", where)
1230
+ if m is None or not os.path.isabs(m.group(1)):
1231
+ return where
1232
+ try:
1233
+ path = os.path.relpath(m.group(1))
1234
+ except ValueError: # on another drive (Windows)
1235
+ return where
1236
+ return where if path == os.pardir or path.startswith(os.pardir + os.sep) else path + m.group(2)
1237
+
1238
+
1190
1239
  def clean(
1191
1240
  project: ProjectArg = None,
1192
1241
  yes: Annotated[bool, typer.Option("--yes", "-y", help="Do not ask for confirmation.")] = False,
@@ -221,6 +221,10 @@ class Ctx(BaseModel):
221
221
  handles: dict[str, dict[int, str]] = Field(default_factory=dict) # variables/switches id -> Python name
222
222
  choice_sep: str | None = None # how the editor joined show_choices options (None: engine default)
223
223
  dynparams: bool = False # DynPlugins/DynParams.dll: variables wherever RPG Maker takes a number
224
+ # most commands of the game are stored the way the Maniac Patch editor writes them
225
+ # (Control Variables of one variable with 0 as last id...): a statement without a
226
+ # `# layout:` comment is compiled that way (see rpgsync.layout)
227
+ maniac_layout: bool = False
224
228
 
225
229
  def enc(self, s: str, node=None) -> bytes:
226
230
  try:
@@ -561,9 +565,19 @@ def C(code, params=(), string=b"") -> Command:
561
565
  TEXT_CODES = {10110: ("text", 20110), 12410: ("comment", 22410)}
562
566
 
563
567
 
568
+ # Maniac Patch: p4 selectors (4 bits each) of width, height, font (0 the command's text,
569
+ # 1 strings[p7], 2 strings[variables[p7]]), font size, character and line spacing;
570
+ # p5-p10 their values (0: unchanged)
571
+ MSG_MANIAC = ["width", "height", "font", "font_size", "char_spacing", "line_spacing"]
572
+
573
+
564
574
  def _msg_options():
565
575
  def dec(c, ctx):
566
- if len(c.params) != 4 or c.string:
576
+ from .maniac_ext import text_src
577
+ from .pyexpr import _fits, mval_src
578
+
579
+ n = len(c.params)
580
+ if n not in (4, 11) or (c.string and n == 4):
567
581
  return None
568
582
  kw = []
569
583
  if c.params[0]:
@@ -573,19 +587,46 @@ def _msg_options():
573
587
  kw.append(("avoid_hero", bool(c.params[2]) if c.params[2] == 1 else c.params[2]))
574
588
  if c.params[3]:
575
589
  kw.append(("continue_events", bool(c.params[3]) if c.params[3] == 1 else c.params[3]))
590
+ if n == 11:
591
+ m = c.params[4]
592
+ if not _fits(m, 6):
593
+ return None
594
+ for slot, name in enumerate(MSG_MANIAC):
595
+ mode, v = (m >> 4 * slot) & 0xF, c.params[5 + slot]
596
+ if name == "font":
597
+ src = text_src(mode, v, ctx.dec(c.string))
598
+ if src is None:
599
+ return None
600
+ if src != '""':
601
+ kw.append((name, Raw(src=src)))
602
+ elif mode or v:
603
+ if mode > 2:
604
+ return None
605
+ kw.append((name, Raw(src=mval_src(mode, v))))
576
606
  return render_call("message_options", [], kw)
577
607
 
578
608
  def enc(call, ctx):
579
- a = Args(call, ["transparent", "position", "avoid_hero", "continue_events"])
580
- return C(
581
- 10120,
582
- [
583
- a.int("transparent", 0),
584
- a.enum("position", ["top", "middle", "bottom"], "bottom"),
585
- a.int("avoid_hero", 0),
586
- a.int("continue_events", 0),
587
- ],
588
- )
609
+ from .maniac_ext import text_val
610
+ from .pyexpr import mval_val
611
+
612
+ a = Args(call, ["transparent", "position", "avoid_hero", "continue_events"] + MSG_MANIAC)
613
+ p = [
614
+ a.int("transparent", 0),
615
+ a.enum("position", ["top", "middle", "bottom"], "bottom"),
616
+ a.int("avoid_hero", 0),
617
+ a.int("continue_events", 0),
618
+ ]
619
+ if not set(MSG_MANIAC) & set(a.values):
620
+ return C(10120, p)
621
+ modes, values, font = 0, [], ""
622
+ for slot, name in enumerate(MSG_MANIAC):
623
+ if name == "font":
624
+ mode, v, font = text_val(a.get(name, ""), "message_options(): font", call.node)
625
+ else:
626
+ mode, v = mval_val(a.get(name, 0), name, modes=3)
627
+ modes |= mode << 4 * slot
628
+ values.append(v)
629
+ return C(10120, p + [modes] + values, ctx.enc(font, call.node))
589
630
 
590
631
  return dec, enc
591
632
 
@@ -594,7 +635,22 @@ rich(10120, "message_options")(_msg_options())
594
635
 
595
636
 
596
637
  def _face():
638
+ # Maniac Patch: p3 selectors of the faceset (0 the command's text, 1 strings[p4],
639
+ # 2 strings[variables[p4]]) and of the index (1 variables[n], 2 variables[variables[n]]);
640
+ # the Maniac editor drops trailing zero parameters (rpgsync.layout)
597
641
  def dec(c, ctx):
642
+ from .maniac_ext import text_src
643
+ from .pyexpr import _fits, mval_src
644
+
645
+ if len(c.params) in (4, 5):
646
+ p = c.params + [0] * (5 - len(c.params))
647
+ if not _fits(p[3], 2) or (p[3] >> 4) > 2 or p[1] not in (0, 1) or p[2] not in (0, 1):
648
+ return None
649
+ face = text_src(p[3] & 0xF, p[4], ctx.dec(c.string))
650
+ if face is None:
651
+ return None
652
+ kw = [(k, True) for k, v in (("right", p[1]), ("flip", p[2])) if v]
653
+ return render_call("face", [Raw(src=face), Raw(src=mval_src(p[3] >> 4, p[0]))], kw)
598
654
  if not c.string:
599
655
  if not c.params:
600
656
  return "face(None)"
@@ -612,9 +668,19 @@ def _face():
612
668
  return render_call("face", [ctx.dec(c.string), c.params[0]], kw)
613
669
 
614
670
  def enc(call, ctx):
671
+ from .maniac_ext import text_val
672
+ from .pyexpr import is_int, mval_val
673
+
615
674
  a = Args(call, ["faceset", "index", "right", "flip"])
616
675
  if a.get("faceset") is None and len(a.values) <= 1:
617
676
  return C(10130)
677
+ face, index = a.get("faceset"), a.get("index", 0)
678
+ if face is not None and not isinstance(face, str) or not is_int(index):
679
+ fmode, fvar, text = text_val(face, "face(): the faceset", call.node)
680
+ imode, i = mval_val(index, "face(): the index", modes=3)
681
+ return C(
682
+ 10130, [i, a.int("right", 0), a.int("flip", 0), fmode | imode << 4, fvar], ctx.enc(text, call.node)
683
+ )
618
684
  return C(10130, [a.int("index", 0), a.int("right", 0), a.int("flip", 0)], ctx.enc(a.str("faceset"), call.node))
619
685
 
620
686
  return dec, enc
@@ -657,20 +723,42 @@ rich(10810, "teleport")(_teleport())
657
723
 
658
724
 
659
725
  def _wait():
726
+ # Maniac Patch: p1 256 = in frames (else tenths of seconds; 1 = until a key is
727
+ # pressed), p2 the selector of the value (1 variables[n], 2 variables[variables[n]])
660
728
  def dec(c, ctx):
661
- n = len(c.params)
662
- if n not in (1, 2) or (n == 1) != (ctx.engine == "2k"):
729
+ from .pyexpr import mval_src
730
+
731
+ p = c.params
732
+ n = len(p)
733
+ if n == 3 and p[2] in (1, 2) and p[1] in (0, 256):
734
+ unit = "frames" if p[1] == 256 else "tenths"
735
+ return render_call("wait", [], [(unit, Raw(src=mval_src(p[2], p[0])))])
736
+ if n == 2 and p[1] == 256:
737
+ return render_call("wait", [], [("frames", p[0])])
738
+ if n not in (1, 2):
663
739
  return None
664
740
  kw = []
665
741
  if n == 2 and c.params[1]:
666
742
  if c.params[1] != 1:
667
743
  return None
668
744
  kw.append(("key", True))
745
+ if n == 1 and ctx.engine != "2k":
746
+ kw.append(("key", None)) # stored without it (RPG Maker 2000's form)
669
747
  t = c.params[0] / 10
670
748
  return render_call("wait", [Raw(src=repr(t))], kw)
671
749
 
672
750
  def enc(call, ctx):
673
- a = Args(call, ["seconds", "key"])
751
+ from .pyexpr import mval_val
752
+
753
+ a = Args(call, ["seconds", "key", "frames", "tenths"])
754
+ for unit, opt in (("frames", 256), ("tenths", 0)):
755
+ if unit in a.values:
756
+ if set(a.values) != {unit}:
757
+ raise CompileError("wait(%s=...) takes no other argument" % unit, call.node)
758
+ mode, v = mval_val(a.get(unit), unit, modes=3)
759
+ if mode == 0 and unit == "tenths":
760
+ raise CompileError("wait(): a number of seconds is wait(seconds)", call.node)
761
+ return C(11410, [v, opt] + ([mode] if mode else []))
674
762
  s = a.get("seconds", 0)
675
763
  if not isinstance(s, (int, float)) or isinstance(s, bool):
676
764
  raise CompileError("wait(): seconds must be a number", call.node)
@@ -678,6 +766,8 @@ def _wait():
678
766
  if abs(tenths - s * 10) > 1e-6:
679
767
  raise CompileError("wait(): RPG Maker waits in steps of 0.1 seconds", call.node)
680
768
  p = [int(tenths)]
769
+ if "key" in a.values and a.get("key") is None:
770
+ return C(11410, p)
681
771
  if ctx.engine != "2k" or a.bool("key"):
682
772
  p.append(int(a.bool("key")))
683
773
  return C(11410, p)
@@ -717,18 +807,52 @@ for _code, _name, _args in [
717
807
 
718
808
 
719
809
  def _sound(code, name, has_fade):
810
+ # Maniac Patch: one more parameter with the selectors (4 bits each) of the name (0 the
811
+ # command's text, 1 strings[n], 2 strings[variables[n]]) and of each value (1
812
+ # variables[n], 2 variables[variables[n]]), then the name's string variable
720
813
  keys = (["fade"] if has_fade else []) + ["volume", "tempo", "balance"]
721
814
  defaults = ([0] if has_fade else []) + [100, 100, 50]
722
815
 
723
816
  def dec(c, ctx):
724
- if len(c.params) != len(keys):
817
+ from .maniac_ext import text_src
818
+ from .pyexpr import _fits, mval_src
819
+
820
+ n = len(keys)
821
+ if len(c.params) == n:
822
+ kw = [(k, v) for k, v, d in zip(keys, c.params, defaults) if v != d]
823
+ return render_call(name, [ctx.dec(c.string)], kw)
824
+ if len(c.params) not in (n + 1, n + 2):
825
+ return None
826
+ m, var = c.params[n], c.params[n + 1] if len(c.params) == n + 2 else 0
827
+ if m == 0 or not _fits(m, n + 1) or (len(c.params) == n + 2) != (m & 0xF != 0):
725
828
  return None
726
- kw = [(k, v) for k, v, d in zip(keys, c.params, defaults) if v != d]
727
- return render_call(name, [ctx.dec(c.string)], kw)
829
+ sound = text_src(m & 0xF, var, ctx.dec(c.string))
830
+ if sound is None:
831
+ return None
832
+ kw = []
833
+ for slot, (k, v, d) in enumerate(zip(keys, c.params, defaults), 1):
834
+ mode = (m >> 4 * slot) & 0xF
835
+ if mode > 2:
836
+ return None
837
+ if mode or v != d:
838
+ kw.append((k, Raw(src=mval_src(mode, v))))
839
+ return render_call(name, [Raw(src=sound)], kw)
728
840
 
729
841
  def enc(call, ctx):
842
+ from .maniac_ext import text_val
843
+ from .pyexpr import mval_val
844
+
730
845
  a = Args(call, ["name"] + keys)
731
- return C(code, [a.int(k, d) for k, d in zip(keys, defaults)], ctx.enc(a.str("name", ""), call.node))
846
+ sound = a.get("name", "")
847
+ if isinstance(sound, str) and all(isinstance(a.get(k, 0), int) for k in keys):
848
+ return C(code, [a.int(k, d) for k, d in zip(keys, defaults)], ctx.enc(a.str("name", ""), call.node))
849
+ mode, var, text = text_val(sound, "%s(): the name" % name, call.node)
850
+ params, modes = [], mode
851
+ for slot, (k, d) in enumerate(zip(keys, defaults), 1):
852
+ kmode, v = mval_val(a.get(k, d), k, modes=3)
853
+ params.append(v)
854
+ modes |= kmode << 4 * slot
855
+ return C(code, params + [modes] + ([var] if mode else []), ctx.enc(text, call.node))
732
856
 
733
857
  return dec, enc
734
858
 
@@ -25,7 +25,7 @@ import os
25
25
  import tomllib
26
26
  from collections.abc import Iterable, Sequence
27
27
 
28
- from . import dynparams, dynrpg, maniac
28
+ from . import dynparams, dynrpg, maniac, maniac_ext
29
29
  from .commands import CODE_NAMES
30
30
  from .lcf import Command
31
31
 
@@ -118,6 +118,14 @@ def command_problems(
118
118
  hint = ' (a %s command: add "%s" to [tool.rpgsync] patches)' % (patch, patch) if patch else ""
119
119
  add(True, "unknown command %d%s" % (code, hint))
120
120
  continue
121
+ if "maniac" not in patches:
122
+ option = maniac_ext.maniac_option(c)
123
+ if option is not None:
124
+ add(
125
+ False,
126
+ '%s uses a Maniac Patch option (%s): add "maniac" to [tool.rpgsync] patches'
127
+ % (describe(code), option),
128
+ )
121
129
  if engine == "2k":
122
130
  if code in ONLY_2K3:
123
131
  add(True, "%s only exists in RPG Maker 2003" % describe(code))