reverseport 1.6.0 → 1.8.0

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 (44) hide show
  1. package/README.md +71 -26
  2. package/THIRD_PARTY_NOTICES.md +1 -0
  3. package/bin/reverseport.mjs +13 -1
  4. package/dist/generatedPackageMetadata.js +1 -1
  5. package/licenses/README.md +1 -0
  6. package/licenses/godot-engine-MIT.txt +20 -0
  7. package/native/windows/build/manifest.json +1 -1
  8. package/package.json +1 -1
  9. package/port/cli.mjs +6 -4
  10. package/port/knowledge/apis/godot-3-to-4-renames.json +1 -0
  11. package/port/knowledge/apis/godot-3.json +1 -0
  12. package/port/knowledge/apis/godot-4.3.json +1 -0
  13. package/port/knowledge/apis/godot-4.7.json +1 -0
  14. package/port/src/agent.mjs +1 -1
  15. package/port/src/agentdisk.mjs +83 -0
  16. package/port/src/apicheck.mjs +280 -0
  17. package/port/src/archive.mjs +34 -4
  18. package/port/src/convert.mjs +3 -1
  19. package/port/src/dormant.mjs +159 -0
  20. package/port/src/formats.mjs +1 -0
  21. package/port/src/gamearchives.mjs +476 -0
  22. package/port/src/godotbin.mjs +544 -0
  23. package/port/src/il2cpp.mjs +148 -0
  24. package/port/src/jsworkspace.mjs +2 -2
  25. package/port/src/knowledge.mjs +3 -1
  26. package/port/src/physics.mjs +135 -0
  27. package/port/src/plan.mjs +94 -11
  28. package/port/src/porters.mjs +87 -5
  29. package/port/src/refs.mjs +120 -0
  30. package/port/src/rpgmaker.mjs +416 -0
  31. package/port/src/save.mjs +326 -0
  32. package/port/src/scene.mjs +37 -3
  33. package/port/src/shader.mjs +385 -0
  34. package/port/src/strings.mjs +327 -0
  35. package/port/src/tools.mjs +48 -8
  36. package/port/src/trace.mjs +302 -0
  37. package/port/src/unity-anim.mjs +229 -0
  38. package/port/src/upgrade.mjs +219 -0
  39. package/skills/build-inspector/SKILL.md +8 -0
  40. package/skills/godot-port/SKILL.md +2 -0
  41. package/skills/port-verify/SKILL.md +20 -9
  42. package/skills/reverse-engineer-anything/SKILL.md +18 -2
  43. package/skills/reverse-engineer-anything/references/porting-and-rebuilds.md +35 -4
  44. package/skills/unity-to-godot/SKILL.md +14 -2
@@ -0,0 +1,416 @@
1
+ // port_rpgmaker: an RPG Maker MV/MZ project (data/*.json + img/ + audio/) → a runnable Godot 4 project.
2
+ // Maps become scenes whose tile layers are drawn from the original tilesets (B–E and A5 exactly, autotiles with
3
+ // their interior piece), events become nodes with their pages kept as data, and a small interpreter runs the most
4
+ // used event commands (text, choices, switches, variables, conditional branches, transfer, wait, self switches,
5
+ // common events). The database (actors, items, system …) is loaded by an autoload. Everything the interpreter does
6
+ // not run is listed per command code, so the remaining work is explicit. The source is only read.
7
+ import fs from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { RPError, requireDir, walk, limit } from './util.mjs';
10
+ import { workspaceDir, isInside } from './workspace.mjs';
11
+
12
+ const SUPPORTED = new Map([[0, 'end'], [101, 'Show Text'], [401, 'text line'], [102, 'Show Choices'], [402, 'When [choice]'], [403, 'When Cancel'], [404, 'end choices'], [108, 'Comment'], [408, 'comment line'], [111, 'Conditional Branch (switch/variable/self switch)'], [411, 'Else'], [412, 'end branch'], [117, 'Common Event'], [121, 'Control Switches'], [122, 'Control Variables (constant/variable/random)'], [123, 'Control Self Switch'], [201, 'Transfer Player'], [230, 'Wait'], [250, 'Play SE'], [241, 'Play BGM'], [115, 'Exit Event Processing'], [112, 'Loop'], [413, 'Repeat Above'], [113, 'Break Loop']]);
13
+ const NAMES = { 103: 'Input Number', 104: 'Select Item', 105: 'Show Scrolling Text', 118: 'Label', 119: 'Jump to Label', 124: 'Control Timer', 125: 'Change Gold', 126: 'Change Items', 127: 'Change Weapons', 128: 'Change Armors', 129: 'Change Party Member', 132: 'Change Battle BGM', 133: 'Change Victory ME', 134: 'Change Save Access', 135: 'Change Menu Access', 136: 'Change Encounter', 137: 'Change Formation Access', 138: 'Change Window Color', 202: 'Set Vehicle Location', 203: 'Set Event Location', 204: 'Scroll Map', 205: 'Set Movement Route', 206: 'Get on/off Vehicle', 211: 'Change Transparency', 212: 'Show Animation', 213: 'Show Balloon Icon', 214: 'Erase Event', 216: 'Change Player Followers', 217: 'Gather Followers', 221: 'Fadeout Screen', 222: 'Fadein Screen', 223: 'Tint Screen', 224: 'Flash Screen', 225: 'Shake Screen', 231: 'Show Picture', 232: 'Move Picture', 233: 'Rotate Picture', 234: 'Tint Picture', 235: 'Erase Picture', 236: 'Set Weather Effect', 242: 'Fadeout BGM', 245: 'Play BGS', 249: 'Play ME', 261: 'Play Movie', 281: 'Change Map Name Display', 282: 'Change Tileset', 283: 'Change Battle Back', 284: 'Change Parallax', 285: 'Get Location Info', 301: 'Battle Processing', 302: 'Shop Processing', 303: 'Name Input Processing', 311: 'Change HP', 312: 'Change MP', 313: 'Change State', 314: 'Recover All', 315: 'Change EXP', 316: 'Change Level', 317: 'Change Parameter', 318: 'Change Skill', 319: 'Change Equipment', 320: 'Change Name', 321: 'Change Class', 322: 'Change Actor Images', 323: 'Change Vehicle Image', 324: 'Change Nickname', 325: 'Change Profile', 351: 'Open Menu Screen', 352: 'Open Save Screen', 353: 'Game Over', 354: 'Return to Title Screen', 355: 'Script', 655: 'script line', 356: 'Plugin Command (MV)', 357: 'Plugin Command (MZ)', 657: 'plugin args', 505: 'move route step', 601: 'If Win', 602: 'If Escape', 603: 'If Lose', 604: 'end battle branch' };
14
+
15
+ const RUNTIME = {
16
+ 'rpg/rpg_database.gd': `extends Node
17
+ ## Autoload "RpgDb": the RPG Maker database (data/*.json) plus game state (switches, variables, self switches).
18
+ ## Ported by ReversePort. JSON files are the original data, loaded as-is.
19
+
20
+ signal message(text: String, face: String)
21
+ signal choices_requested(options: PackedStringArray)
22
+
23
+ var system: Dictionary = {}
24
+ var map_infos: Array = []
25
+ var common_events: Array = []
26
+ var tilesets: Array = []
27
+ var actors: Array = []
28
+ var items: Array = []
29
+ var switches: Dictionary = {}
30
+ var variables: Dictionary = {}
31
+ var self_switches: Dictionary = {}
32
+ var choice_result := -1
33
+
34
+ func _ready() -> void:
35
+ \tsystem = _load("System")
36
+ \tmap_infos = _load("MapInfos")
37
+ \tcommon_events = _load("CommonEvents")
38
+ \ttilesets = _load("Tilesets")
39
+ \tactors = _load("Actors")
40
+ \titems = _load("Items")
41
+
42
+ func _load(name: String):
43
+ \tvar path := "res://data/%s.json" % name
44
+ \tif not FileAccess.file_exists(path):
45
+ \t\treturn [] if name != "System" else {}
46
+ \treturn JSON.parse_string(FileAccess.get_file_as_string(path))
47
+
48
+ func load_map(id: int) -> Dictionary:
49
+ \treturn _load("Map%03d" % id)
50
+
51
+ func map_scene(id: int) -> String:
52
+ \treturn "res://maps/map_%03d.tscn" % id
53
+
54
+ func get_switch(id: int) -> bool:
55
+ \treturn switches.get(id, false)
56
+
57
+ func set_switch(id: int, value: bool) -> void:
58
+ \tswitches[id] = value
59
+
60
+ func get_variable(id: int) -> int:
61
+ \treturn variables.get(id, 0)
62
+
63
+ func set_variable(id: int, value: int) -> void:
64
+ \tvariables[id] = value
65
+
66
+ func get_self_switch(map_id: int, event_id: int, letter: String) -> bool:
67
+ \treturn self_switches.get("%d,%d,%s" % [map_id, event_id, letter], false)
68
+
69
+ func set_self_switch(map_id: int, event_id: int, letter: String, value: bool) -> void:
70
+ \tself_switches["%d,%d,%s" % [map_id, event_id, letter]] = value
71
+ `,
72
+ 'rpg/rpg_map.gd': `extends Node2D
73
+ ## A ported RPG Maker map: draws its tile layers from the original tileset images.
74
+ ## B–E and A5 tiles are drawn exactly; autotiles (A1–A4) are drawn with their interior piece (edges approximated).
75
+
76
+ @export var map_id := 1
77
+ @export var tile_size := 48
78
+ var data: Dictionary = {}
79
+ var sheets: Array[Texture2D] = []
80
+
81
+ func _ready() -> void:
82
+ \tdata = RpgDb.load_map(map_id)
83
+ \tvar ts_id: int = data.get("tilesetId", 1)
84
+ \tvar ts = RpgDb.tilesets[ts_id] if ts_id < RpgDb.tilesets.size() else null
85
+ \tsheets.clear()
86
+ \tif ts:
87
+ \t\tfor name in ts["tilesetNames"]:
88
+ \t\t\tvar p := "res://img/tilesets/%s.png" % name
89
+ \t\t\tsheets.append(load(p) if name != "" and ResourceLoader.exists(p) else null)
90
+ \tqueue_redraw()
91
+
92
+ func _draw() -> void:
93
+ \tif data.is_empty():
94
+ \t\treturn
95
+ \tvar w: int = data["width"]
96
+ \tvar h: int = data["height"]
97
+ \tvar tiles: Array = data["data"]
98
+ \tfor z in range(4):
99
+ \t\tfor y in range(h):
100
+ \t\t\tfor x in range(w):
101
+ \t\t\t\tvar id: int = tiles[(z * h + y) * w + x]
102
+ \t\t\t\tif id > 0:
103
+ \t\t\t\t\t_draw_tile(id, Vector2(x * tile_size, y * tile_size))
104
+
105
+ func _draw_tile(id: int, at: Vector2) -> void:
106
+ \tvar s := tile_size
107
+ \tif id < 2048:
108
+ \t\tvar set_number := 4 if id >= 1536 else 5 + id / 256
109
+ \t\tvar tex: Texture2D = sheets[set_number] if set_number < sheets.size() else null
110
+ \t\tif tex == null:
111
+ \t\t\treturn
112
+ \t\tvar sx := ((id / 128) % 2 * 8 + id % 8) * s
113
+ \t\tvar sy := ((id % 256) / 8 % 16) * s
114
+ \t\tdraw_texture_rect_region(tex, Rect2(at, Vector2(s, s)), Rect2(sx, sy, s, s))
115
+ \t\treturn
116
+ \tvar kind := (id - 2048) / 48
117
+ \tvar set_number := 0 if id < 2816 else 1 if id < 4352 else 2 if id < 5888 else 3
118
+ \tvar tex2: Texture2D = sheets[set_number] if set_number < sheets.size() else null
119
+ \tif tex2 == null:
120
+ \t\treturn
121
+ \tvar tx := kind % 8
122
+ \tvar ty := kind / 8
123
+ \tvar bx := 0
124
+ \tvar by := 0
125
+ \tif set_number == 0:
126
+ \t\tbx = [0, 0, 6, 6][kind] if kind < 4 else (tx / 4) * 8 + (2 if kind % 2 == 1 else 0)
127
+ \t\tby = [0, 3, 0, 3][kind] if kind < 4 else ty * 6 + (tx / 2) % 2 * 3
128
+ \telif set_number == 1:
129
+ \t\tbx = tx * 2
130
+ \t\tby = (ty - 2) * 3
131
+ \telif set_number == 2:
132
+ \t\tbx = tx * 2
133
+ \t\tby = (ty - 6) * 2
134
+ \telse:
135
+ \t\tbx = tx * 2
136
+ \t\tby = int(floor((ty - 10) * 2.5 + (0.5 if ty % 2 == 1 else 0.0)))
137
+ \t# Interior piece: the four inner quarters of the autotile block.
138
+ \tvar half := s / 2
139
+ \tvar quarters := [[2, 4], [1, 4], [2, 3], [1, 3]]
140
+ \tfor i in range(4):
141
+ \t\tvar q: Array = quarters[i]
142
+ \t\tvar src := Rect2((bx * 2 + q[0]) * half, (by * 2 + q[1]) * half, half, half)
143
+ \t\tvar dst := Rect2(at + Vector2((i % 2) * half, (i / 2) * half), Vector2(half, half))
144
+ \t\tdraw_texture_rect_region(tex2, dst, src)
145
+ `,
146
+ 'rpg/rpg_event.gd': `extends Node2D
147
+ ## A ported RPG Maker event: pages, conditions and a list interpreter for the common commands.
148
+ ## Commands the interpreter does not run are reported once with push_warning (code and name), see PORT-NOTES.md.
149
+
150
+ @export var map_id := 1
151
+ @export var event_id := 1
152
+ @export var trigger_override := -1
153
+ var pages: Array = []
154
+ var running := false
155
+
156
+ func _ready() -> void:
157
+ \tvar map := RpgDb.load_map(map_id)
158
+ \tvar ev = map["events"][event_id] if event_id < map["events"].size() else null
159
+ \tif ev:
160
+ \t\tpages = ev["pages"]
161
+ \tvar page: Variant = active_page()
162
+ \tif page != null and int(page["trigger"]) == 3:
163
+ \t\trun()
164
+
165
+ func active_page() -> Variant:
166
+ \tfor i in range(pages.size() - 1, -1, -1):
167
+ \t\tvar c: Dictionary = pages[i]["conditions"]
168
+ \t\tif c["switch1Valid"] and not RpgDb.get_switch(c["switch1Id"]):
169
+ \t\t\tcontinue
170
+ \t\tif c["switch2Valid"] and not RpgDb.get_switch(c["switch2Id"]):
171
+ \t\t\tcontinue
172
+ \t\tif c["variableValid"] and RpgDb.get_variable(c["variableId"]) < int(c["variableValue"]):
173
+ \t\t\tcontinue
174
+ \t\tif c["selfSwitchValid"] and not RpgDb.get_self_switch(map_id, event_id, c["selfSwitchCh"]):
175
+ \t\t\tcontinue
176
+ \t\treturn pages[i]
177
+ \treturn null
178
+
179
+ func run() -> void:
180
+ \tif running:
181
+ \t\treturn
182
+ \tvar page: Variant = active_page()
183
+ \tif page == null:
184
+ \t\treturn
185
+ \trunning = true
186
+ \tawait run_list(page["list"])
187
+ \trunning = false
188
+
189
+ func run_list(list: Array) -> void:
190
+ \tvar i := 0
191
+ \twhile i < list.size():
192
+ \t\tvar cmd: Dictionary = list[i]
193
+ \t\tvar code: int = cmd["code"]
194
+ \t\tvar p: Array = cmd["parameters"]
195
+ \t\tmatch code:
196
+ \t\t\t0, 108, 408, 412, 404:
197
+ \t\t\t\tpass
198
+ \t\t\t101:
199
+ \t\t\t\tvar text := PackedStringArray()
200
+ \t\t\t\twhile i + 1 < list.size() and int(list[i + 1]["code"]) == 401:
201
+ \t\t\t\t\ti += 1
202
+ \t\t\t\t\ttext.append(list[i]["parameters"][0])
203
+ \t\t\t\tRpgDb.message.emit("\\n".join(text), str(p[0]))
204
+ \t\t\t\tawait get_tree().process_frame
205
+ \t\t\t102:
206
+ \t\t\t\tRpgDb.choice_result = -1
207
+ \t\t\t\tRpgDb.choices_requested.emit(PackedStringArray(p[0]))
208
+ \t\t\t\twhile RpgDb.choice_result < 0:
209
+ \t\t\t\t\tawait get_tree().process_frame
210
+ \t\t\t\ti = _skip_to_choice(list, i, RpgDb.choice_result, cmd["indent"])
211
+ \t\t\t\tcontinue
212
+ \t\t\t402, 403:
213
+ \t\t\t\ti = _skip_block(list, i, cmd["indent"], [404])
214
+ \t\t\t\tcontinue
215
+ \t\t\t111:
216
+ \t\t\t\tif not _condition(p):
217
+ \t\t\t\t\ti = _skip_block(list, i, cmd["indent"], [411, 412]) + 1
218
+ \t\t\t\t\tcontinue
219
+ \t\t\t411:
220
+ \t\t\t\ti = _skip_block(list, i, cmd["indent"], [412])
221
+ \t\t\t\tcontinue
222
+ \t\t\t112, 413:
223
+ \t\t\t\tif code == 413:
224
+ \t\t\t\t\ti = _loop_start(list, i, cmd["indent"])
225
+ \t\t\t\t\tcontinue
226
+ \t\t\t113:
227
+ \t\t\t\ti = _skip_block(list, _loop_end(list, i, cmd["indent"]), cmd["indent"] - 1, [413]) + 1
228
+ \t\t\t\tcontinue
229
+ \t\t\t115:
230
+ \t\t\t\treturn
231
+ \t\t\t117:
232
+ \t\t\t\tvar ce = RpgDb.common_events[p[0]] if int(p[0]) < RpgDb.common_events.size() else null
233
+ \t\t\t\tif ce:
234
+ \t\t\t\t\tawait run_list(ce["list"])
235
+ \t\t\t121:
236
+ \t\t\t\tfor id in range(int(p[0]), int(p[1]) + 1):
237
+ \t\t\t\t\tRpgDb.set_switch(id, int(p[2]) == 0)
238
+ \t\t\t122:
239
+ \t\t\t\tvar value := 0
240
+ \t\t\t\tmatch int(p[3]):
241
+ \t\t\t\t\t0: value = int(p[4])
242
+ \t\t\t\t\t1: value = RpgDb.get_variable(int(p[4]))
243
+ \t\t\t\t\t2: value = randi_range(int(p[4]), int(p[5]))
244
+ \t\t\t\t\t_: push_warning("PORT: Control Variables operand %d" % int(p[3]))
245
+ \t\t\t\tfor id in range(int(p[0]), int(p[1]) + 1):
246
+ \t\t\t\t\tvar old := RpgDb.get_variable(id)
247
+ \t\t\t\t\tvar ops := [value, old + value, old - value, old * value, old / value if value != 0 else old, old % value if value != 0 else old]
248
+ \t\t\t\t\tRpgDb.set_variable(id, ops[int(p[2])])
249
+ \t\t\t123:
250
+ \t\t\t\tRpgDb.set_self_switch(map_id, event_id, p[0], int(p[1]) == 0)
251
+ \t\t\t201:
252
+ \t\t\t\tif int(p[0]) == 0:
253
+ \t\t\t\t\tget_tree().change_scene_to_file(RpgDb.map_scene(int(p[1])))
254
+ \t\t\t\t\treturn
255
+ \t\t\t\tpush_warning("PORT: Transfer Player with variables")
256
+ \t\t\t230:
257
+ \t\t\t\tawait get_tree().create_timer(int(p[0]) / 60.0).timeout
258
+ \t\t\t241, 250:
259
+ \t\t\t\tvar folder := "bgm" if code == 241 else "se"
260
+ \t\t\t\tvar base := "res://audio/%s/%s" % [folder, p[0]["name"]]
261
+ \t\t\t\tfor ext in [".ogg", ".m4a", ".wav"]:
262
+ \t\t\t\t\tif ResourceLoader.exists(base + ext):
263
+ \t\t\t\t\t\tvar player := AudioStreamPlayer.new()
264
+ \t\t\t\t\t\tplayer.stream = load(base + ext)
265
+ \t\t\t\t\t\tadd_child(player)
266
+ \t\t\t\t\t\tplayer.play()
267
+ \t\t\t\t\t\tbreak
268
+ \t\t\t_:
269
+ \t\t\t\tpush_warning("PORT: event command %d not ported (map %d, event %d)" % [code, map_id, event_id])
270
+ \t\ti += 1
271
+
272
+ func _condition(p: Array) -> bool:
273
+ \tmatch int(p[0]):
274
+ \t\t0: return RpgDb.get_switch(int(p[1])) == (int(p[2]) == 0)
275
+ \t\t1:
276
+ \t\t\tvar a := RpgDb.get_variable(int(p[1]))
277
+ \t\t\tvar b := int(p[3]) if int(p[2]) == 0 else RpgDb.get_variable(int(p[3]))
278
+ \t\t\treturn [a == b, a >= b, a <= b, a > b, a < b, a != b][int(p[4])]
279
+ \t\t2: return RpgDb.get_self_switch(map_id, event_id, p[1]) == (int(p[2]) == 0)
280
+ \tpush_warning("PORT: Conditional Branch type %d" % int(p[0]))
281
+ \treturn false
282
+
283
+ func _skip_block(list: Array, i: int, indent: int, stop_codes: Array) -> int:
284
+ \tvar j := i + 1
285
+ \twhile j < list.size():
286
+ \t\tif int(list[j]["indent"]) == indent and stop_codes.has(int(list[j]["code"])):
287
+ \t\t\treturn j
288
+ \t\tj += 1
289
+ \treturn j
290
+
291
+ func _skip_to_choice(list: Array, i: int, choice: int, indent: int) -> int:
292
+ \tvar j := i + 1
293
+ \twhile j < list.size():
294
+ \t\tvar c: Dictionary = list[j]
295
+ \t\tif int(c["indent"]) == indent and int(c["code"]) == 402 and int(c["parameters"][0]) == choice:
296
+ \t\t\treturn j + 1
297
+ \t\tif int(c["indent"]) == indent and int(c["code"]) == 404:
298
+ \t\t\treturn j
299
+ \t\tj += 1
300
+ \treturn j
301
+
302
+ func _loop_start(list: Array, i: int, indent: int) -> int:
303
+ \tvar j := i - 1
304
+ \twhile j >= 0 and not (int(list[j]["code"]) == 112 and int(list[j]["indent"]) == indent):
305
+ \t\tj -= 1
306
+ \treturn j + 1
307
+
308
+ func _loop_end(list: Array, i: int, indent: int) -> int:
309
+ \tvar j := i
310
+ \twhile j < list.size() and not (int(list[j]["code"]) == 413):
311
+ \t\tj += 1
312
+ \treturn j
313
+ `,
314
+ 'rpg/rpg_message_box.gd': `extends CanvasLayer
315
+ ## Minimal message window and choice list for ported events (replace with your own UI).
316
+
317
+ var label: Label
318
+ var box: VBoxContainer
319
+
320
+ func _ready() -> void:
321
+ \tlabel = Label.new()
322
+ \tlabel.position = Vector2(24, 460)
323
+ \tlabel.visible = false
324
+ \tadd_child(label)
325
+ \tbox = VBoxContainer.new()
326
+ \tbox.position = Vector2(560, 300)
327
+ \tadd_child(box)
328
+ \tRpgDb.message.connect(_on_message)
329
+ \tRpgDb.choices_requested.connect(_on_choices)
330
+
331
+ func _on_message(text: String, _face: String) -> void:
332
+ \tlabel.text = text
333
+ \tlabel.visible = true
334
+
335
+ func _on_choices(options: PackedStringArray) -> void:
336
+ \tfor c in box.get_children():
337
+ \t\tc.queue_free()
338
+ \tfor i in range(options.size()):
339
+ \t\tvar b := Button.new()
340
+ \t\tb.text = options[i]
341
+ \t\tb.pressed.connect(func(): RpgDb.choice_result = i; for c in box.get_children(): c.queue_free())
342
+ \t\tbox.add_child(b)
343
+ `,
344
+ };
345
+
346
+ export async function portRpgMakerTool({ path: source, out, limit: max = 300 } = {}) {
347
+ const root = await requireDir(source, 'RPG Maker project');
348
+ const dataDir = (await fs.stat(path.join(root, 'data')).catch(() => null)) ? path.join(root, 'data') : (await fs.stat(path.join(root, 'www', 'data')).catch(() => null)) ? path.join(root, 'www', 'data') : null;
349
+ if (!dataDir) throw new RPError('NOT_RPGMAKER', 'No data/ folder with System.json found (RPG Maker MV/MZ project or www/ of a deployed game).');
350
+ const base = path.dirname(dataDir);
351
+ const readJson = async f => JSON.parse(await fs.readFile(path.join(dataDir, f), 'utf8'));
352
+ const system = await readJson('System.json').catch(() => { throw new RPError('NOT_RPGMAKER', 'data/System.json is missing or unreadable.'); });
353
+ const encrypted = !!(system.hasEncryptedImages || system.hasEncryptedAudio);
354
+ const mz = await fs.stat(path.join(base, 'js', 'rmmz_core.js')).then(() => true).catch(() => false);
355
+ const mapInfos = await readJson('MapInfos.json').catch(() => []);
356
+ const outDir = out ? path.resolve(out) : path.join(await workspaceDir('ports'), `${path.basename(root).replace(/[^A-Za-z0-9._-]+/g, '_')}-godot`);
357
+ if (isInside(outDir, root) || isInside(root, outDir)) throw new RPError('INVALID_ARGUMENT', 'out must be outside the source project.');
358
+ if ((await fs.readdir(outDir).catch(() => [])).length) throw new RPError('OUTPUT_NOT_EMPTY', `out is not empty: ${outDir}`);
359
+ await fs.mkdir(outDir, { recursive: true });
360
+
361
+ // Copy data, images and audio as they are (encrypted .rpgmvp/.png_ files are copied but cannot be used).
362
+ const { files } = await walk(base, { maxFiles: 100000 });
363
+ let copied = 0;
364
+ for (const f of files) {
365
+ const rel = f.rel.split(path.sep).join('/');
366
+ if (!/^(data|img|audio|fonts|movies)\//.test(rel)) continue;
367
+ const dest = path.join(outDir, rel);
368
+ await fs.mkdir(path.dirname(dest), { recursive: true });
369
+ await fs.copyFile(f.abs, dest);
370
+ copied++;
371
+ }
372
+ for (const [rel, text] of Object.entries(RUNTIME)) { const d = path.join(outDir, rel); await fs.mkdir(path.dirname(d), { recursive: true }); await fs.writeFile(d, text); }
373
+
374
+ // Maps → scenes, and a census of event commands.
375
+ const tile = mz ? (system.tileSize ?? 48) : 48;
376
+ const census = new Map();
377
+ const maps = [];
378
+ const count = list => { for (const c of list ?? []) census.set(c.code, (census.get(c.code) ?? 0) + 1); };
379
+ for (const ce of await readJson('CommonEvents.json').catch(() => [])) if (ce) count(ce.list);
380
+ for (const info of mapInfos) {
381
+ if (!info) continue;
382
+ const id = info.id;
383
+ const map = await readJson(`Map${String(id).padStart(3, '0')}.json`).catch(() => null);
384
+ if (!map) continue;
385
+ const lines = [`[gd_scene load_steps=3 format=3]`, '', '[ext_resource type="Script" path="res://rpg/rpg_map.gd" id="1_map"]', '[ext_resource type="Script" path="res://rpg/rpg_event.gd" id="2_event"]', '', `[node name="Map${String(id).padStart(3, '0')}" type="Node2D"]`, 'script = ExtResource("1_map")', `map_id = ${id}`, `tile_size = ${tile}`, `metadata/display_name = ${JSON.stringify(map.displayName || info.name || '')}`];
386
+ let events = 0;
387
+ for (const ev of map.events ?? []) {
388
+ if (!ev) continue;
389
+ for (const pg of ev.pages ?? []) count(pg.list);
390
+ const name = `EV${String(ev.id).padStart(3, '0')}_${String(ev.name || 'event').replace(/[^A-Za-z0-9_]+/g, '_')}`;
391
+ lines.push('', `[node name="${name}" type="Node2D" parent="."]`, `position = Vector2(${ev.x * tile + tile / 2}, ${ev.y * tile + tile / 2})`, 'script = ExtResource("2_event")', `map_id = ${id}`, `event_id = ${ev.id}`);
392
+ events++;
393
+ }
394
+ const sceneRel = `maps/map_${String(id).padStart(3, '0')}.tscn`;
395
+ await fs.mkdir(path.join(outDir, 'maps'), { recursive: true });
396
+ await fs.writeFile(path.join(outDir, sceneRel), lines.join('\n') + '\n');
397
+ maps.push({ id, name: info.name, size: `${map.width}x${map.height}`, events, scene: sceneRel });
398
+ }
399
+ // Start scene: the start map with the player marker and the message box.
400
+ const startMap = system.startMapId ?? maps[0]?.id ?? 1;
401
+ const startScene = `res://maps/map_${String(startMap).padStart(3, '0')}.tscn`;
402
+ await fs.writeFile(path.join(outDir, 'main.tscn'), `[gd_scene load_steps=3 format=3]\n\n[ext_resource type="PackedScene" path="${startScene}" id="1_map"]\n[ext_resource type="Script" path="res://rpg/rpg_message_box.gd" id="2_msg"]\n\n[node name="Main" type="Node2D"]\n\n[node name="Map" parent="." instance=ExtResource("1_map")]\n\n[node name="Player" type="Marker2D" parent="."]\nposition = Vector2(${(system.startX ?? 0) * tile + tile / 2}, ${(system.startY ?? 0) * tile + tile / 2})\n\n[node name="MessageBox" type="CanvasLayer" parent="."]\nscript = ExtResource("2_msg")\n`);
403
+ const width = mz ? (system.advanced?.screenWidth ?? 816) : 816, height = mz ? (system.advanced?.screenHeight ?? 624) : 624;
404
+ await fs.writeFile(path.join(outDir, 'project.godot'), `; Engine configuration file.\n; Ported by ReversePort from an RPG Maker ${mz ? 'MZ' : 'MV'} project.\n\nconfig_version=5\n\n[application]\n\nconfig/name=${JSON.stringify(system.gameTitle || path.basename(root))}\nrun/main_scene="res://main.tscn"\nconfig/features=PackedStringArray("4.3")\n\n[autoload]\n\nRpgDb="*res://rpg/rpg_database.gd"\n\n[display]\n\nwindow/size/viewport_width=${width}\nwindow/size/viewport_height=${height}\n\n[rendering]\n\ntextures/canvas_textures/default_texture_filter=0\n`);
405
+
406
+ const commands = [...census.entries()].sort((a, b) => b[1] - a[1]).map(([code, n]) => ({ code, name: SUPPORTED.get(code) ?? NAMES[code] ?? `code ${code}`, count: n, ported: SUPPORTED.has(code) }));
407
+ const missing = commands.filter(c => !c.ported);
408
+ const notes = ['# Port notes', '', `Ported by ReversePort from RPG Maker ${mz ? 'MZ' : 'MV'}: ${maps.length} maps, ${copied} data/asset files.`, '', '## Event commands not run by rpg_event.gd', '', ...(missing.length ? missing.map(c => `- ${c.code} ${c.name}: ${c.count}×`) : ['- none']), '', '## Also by hand', '', '- Player movement, collision (tileset flags in data/Tilesets.json), followers and vehicles.', '- Menus, battle system, shops and save/load.', '- Autotile edges (A1–A4 are drawn with their interior piece), animated water and parallax.', '- Plugins (js/plugins.js): each needs its own port.', ...(encrypted ? ['- Images/audio are encrypted in the source (.rpgmvp/.png_ etc.); they are copied but not decrypted. Use the unencrypted project.'] : [])];
409
+ await fs.writeFile(path.join(outDir, 'PORT-NOTES.md'), notes.join('\n') + '\n');
410
+ return {
411
+ source: root, out: outDir, engine: mz ? 'RPG Maker MZ' : 'RPG Maker MV', maps: limit(maps, max), copied, tileSize: tile, startMap,
412
+ commands: { total: commands.reduce((s, c) => s + c.count, 0), ported: commands.filter(c => c.ported).reduce((s, c) => s + c.count, 0), missing: limit(missing, 60) },
413
+ ...(encrypted ? { encrypted: 'images/audio are encrypted in this project: copied, never decrypted; use the unencrypted project' } : {}),
414
+ next: `Open ${outDir} in Godot 4.3+ and run: the start map draws and its events run. PORT-NOTES.md lists the event commands and systems still to port (movement, menus, battles first). Text: re_strings on the source for translation.`,
415
+ };
416
+ }