Repository navigation
Expand file tree
/
Copy pathshell.html
More file actions
1231 lines (1146 loc) · 54.2 KB
/
Copy pathshell.html
File metadata and controls
1231 lines (1146 loc) · 54.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!doctype html>
<!--
Custom Emscripten shell for the RPG Maker clone browser build.
Two things set it apart from the stock shell:
* Runtime project loading. The page can load an RPG Maker 2000/2003 (RPG2k)
or XP project into the virtual filesystem at runtime, so a single build
serves any game instead of one baked in at compile time. The loader takes
a local .zip, a direct .zip URL, or a GitHub repo (owner/repo or a
github.com URL, resolved to its codeload archive). The zip is unpacked in
the browser with the native DecompressionStream API (no third-party code),
the directory holding RPG_RT.ldb (RPG2k) or Game.ini (XP) is located and
mounted at /game, and the exported rpg_start_game() starts the game.
Downloaded archives are cached (Cache Storage) so re-loading is instant.
* On-screen game keypad. Plain <button> elements beneath the canvas make the
game playable by touch or mouse without a physical keyboard. Each button's
pointer handlers call Module._rgss_wasm_key(id, press), exported from
src/wasm_keypad.cxx, which feeds the same input path as the SDL keyboard
watch. The physical keyboard keeps working exactly as before.
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
/>
<title>RPG Maker Clone</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22512%22%20height%3D%22512%22%20viewBox%3D%220%200%20512%20512%22%3E%0A%20%20%3Crect%20width%3D%22512%22%20height%3D%22512%22%20rx%3D%2296%22%20ry%3D%2296%22%20fill%3D%22%23191c2b%22%2F%3E%0A%20%20%3Cpolygon%20points%3D%22160%2C80%20352%2C80%20416%2C160%20256%2C368%2096%2C160%22%20fill%3D%22%231e4a8c%22%2F%3E%0A%20%20%3Cpolygon%20points%3D%22160%2C80%20256%2C176%2096%2C160%22%20fill%3D%22%23a3701f%22%20opacity%3D%220.55%22%2F%3E%0A%20%20%3Cpath%20d%3D%22M160%2C80%20L256%2C176%20M352%2C80%20L256%2C176%20M96%2C160%20L256%2C176%20M416%2C160%20L256%2C176%20M256%2C176%20L256%2C368%22%0A%20%20%20%20%20%20%20%20stroke%3D%22%23eae3d3%22%20stroke-width%3D%2211%22%20fill%3D%22none%22%2F%3E%0A%20%20%3Crect%20x%3D%22232%22%20y%3D%22384%22%20width%3D%2248%22%20height%3D%2248%22%20fill%3D%22%23a3701f%22%2F%3E%0A%3C%2Fsvg%3E" />
<style>
:root {
color-scheme: dark;
--bg: #14161a;
--panel: #1e2127;
--border: #333842;
--fg: #e6e8eb;
--muted: #9aa1ac;
--accent: #4f8cff;
--accent-fg: #ffffff;
--error: #ff6b6b;
}
* { box-sizing: border-box; }
/* The loader panel is dismissed by setting `hidden` on it, but the UA's
`[hidden] { display: none }` loses to the `display: flex` rules below,
so it stayed on screen above the running game. Author-level and
!important so any element hidden here really disappears. */
[hidden] { display: none !important; }
html, body { margin: 0; }
body {
min-height: 100vh;
background: var(--bg);
color: var(--fg);
font: 14px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 24px 16px 48px;
/* Stop the browser from treating taps/drags on the page chrome as
scroll or double-tap-zoom gestures while playing. */
overscroll-behavior: none;
}
h1 { font-size: 18px; margin: 0; font-weight: 600; }
h1 small { color: var(--muted); font-weight: 400; font-size: 13px; }
#game {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
canvas {
background: #000;
border: 1px solid var(--border);
border-radius: 6px;
/* The page-wide border-box rule would take the 1px border out of the
size fitCanvas() sets, leaving a fractional scale (638/320 instead of
2) that resamples every game pixel. This is the one element whose CSS
size has to be the pixel grid itself, so keep the border outside it. */
box-sizing: content-box;
/* SDL sets the intrinsic pixel size -- one canvas pixel per game pixel,
since the browser display is never zoomed engine-side (src/main.cxx).
The zooming happens here: fitCanvas() below sets an explicit CSS size
and this keeps the upscale crisp, with max-width as the fallback that
already fits the canvas on a small screen before the script runs.
touch-action:none keeps taps off the canvas from scrolling the page. */
image-rendering: pixelated;
max-width: 100%;
touch-action: none;
}
.panel {
width: min(560px, 100%);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.row > input[type="text"] { flex: 1 1 240px; }
label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
input[type="text"] {
background: #12141a;
color: var(--fg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
font: inherit;
min-width: 0;
}
input[type="text"]:focus { outline: none; border-color: var(--accent); }
button {
background: var(--accent);
color: var(--accent-fg);
border: 0;
border-radius: 6px;
padding: 8px 16px;
font: inherit;
font-weight: 600;
cursor: pointer;
}
button:disabled { opacity: .5; cursor: default; }
.hint { color: var(--muted); font-size: 12px; margin: 0; text-align: center; }
.hint code { background: #12141a; padding: 1px 5px; border-radius: 4px; }
.hint a { color: var(--accent); text-decoration: none; }
.hint a:hover { text-decoration: underline; }
.divider { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 12px; }
.divider::before, .divider::after { content: ""; flex: 1; height: 1px; background: var(--border); }
label.check { text-transform: none; letter-spacing: 0; font-size: 12px; color: var(--muted); display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
#clearcache { color: var(--accent); font-size: 12px; text-decoration: none; }
#clearcache:hover { text-decoration: underline; }
#status { min-height: 20px; font-size: 13px; }
#status.error { color: var(--error); }
#status.busy { color: var(--accent); }
details { width: min(560px, 100%); }
summary { cursor: pointer; color: var(--muted); font-size: 12px; }
#log, #crashText {
margin-top: 8px;
max-height: 180px;
overflow: auto;
background: #0c0e12;
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--muted);
white-space: pre-wrap;
word-break: break-word;
}
/* --- Crash report ------------------------------------------------- */
/* Shown when the engine (or the page) hits an error, holding the report
the buttons copy. Bordered in the error colour so it is unmistakably
not part of the normal loader chrome. */
#crash { border-color: var(--error); }
#crash h2 { font-size: 15px; margin: 0; color: var(--error); }
#crash .hint { text-align: left; margin: 0; }
#crash a { color: var(--accent); font-size: 12px; text-decoration: none; }
#crash a:hover { text-decoration: underline; }
button.secondary {
background: transparent;
color: var(--fg);
border: 1px solid var(--border);
}
.copied { color: var(--accent); font-size: 12px; }
/* --- On-screen keypad -------------------------------------------- */
#keypad {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
width: 100%;
max-width: 640px;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
#keypad button {
font-family: inherit;
font-weight: 600;
color: #eee;
background: #2a2a2e;
border: 1px solid #45454b;
border-radius: 10px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
#keypad button.active {
background: var(--accent);
border-color: #6f97ff;
}
/* D-pad: a 3x3 grid with only the four arrows filled in. */
#dpad {
display: grid;
grid-template-columns: repeat(3, 56px);
grid-template-rows: repeat(3, 56px);
gap: 6px;
}
#dpad button { font-size: 22px; padding: 0; }
#btn-up { grid-column: 2; grid-row: 1; }
#btn-left { grid-column: 1; grid-row: 2; }
#btn-right { grid-column: 3; grid-row: 2; }
#btn-down { grid-column: 2; grid-row: 3; }
/* Action cluster on the right. */
#actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
#shoulders, #face, #extras { display: flex; gap: 8px; }
#actions button { min-width: 58px; height: 44px; padding: 0 10px; font-size: 14px; }
#face button { min-width: 64px; height: 50px; }
@media (max-width: 480px) {
#keypad { gap: 12px; }
#dpad {
grid-template-columns: repeat(3, 48px);
grid-template-rows: repeat(3, 48px);
}
#actions button { min-width: 48px; }
}
</style>
</head>
<body>
<h1>RPG Maker clone <small>— load a project to play</small></h1>
<p class="hint"><a href="changelog/">Changelog</a></p>
<div class="panel" id="loader">
<!-- Shown only when a sample project is bundled into the page's virtual
filesystem at /game_sample (see WASM_SAMPLE_DIR in CMakeLists.txt). -->
<div id="presetRow" style="display:none">
<label>Bundled sample project</label>
<div class="row" style="margin-top:6px">
<button id="loadSample">▶ Run the MV sample</button>
</div>
<div class="divider">or open your own</div>
</div>
<div>
<label for="file">Open a local project (.zip)</label>
<div class="row" style="margin-top:6px">
<input type="file" id="file" accept=".zip,application/zip" />
</div>
</div>
<div class="divider">or fetch by URL</div>
<div>
<label for="url">Zip URL or GitHub repo</label>
<div class="row" style="margin-top:6px">
<input type="text" id="url" placeholder="owner/repo · https://github.com/owner/repo · https://host/game.zip" />
<button id="load" disabled>Load</button>
</div>
<div class="row" style="margin-top:8px; gap:14px">
<label class="check" for="nocache"><input type="checkbox" id="nocache" /> Re-download (ignore cache)</label>
<a href="#" id="clearcache">Clear cached archives</a>
</div>
</div>
<details>
<summary>Fetching from GitHub / another site not working?</summary>
<p class="hint" style="margin-top:8px; text-align:left">
Browsers block cross-origin downloads unless the host opts in (CORS).
GitHub's archive endpoint does not, so a GitHub repo or an arbitrary
<code>.zip</code> URL usually needs a CORS proxy — put its prefix below,
or just download the zip and use <em>Open a local project</em> above,
which always works.
</p>
<label for="proxy">CORS proxy prefix (optional)</label>
<div class="row" style="margin-top:6px">
<input type="text" id="proxy" placeholder="https://corsproxy.io/?url=" />
</div>
</details>
<div id="status"></div>
</div>
<!-- The canvas is present and visible from the start: main() creates the
SDL / WebGL context at page load, and a display:none canvas can break
context creation in some renderer paths. It shows black until a game
starts. The keypad below no-ops until the runtime is up. -->
<div id="game">
<canvas id="canvas" tabindex="-1" oncontextmenu="event.preventDefault()"></canvas>
<!-- Keys carry the numeric RGSS::Input id (see enum RgssKey in
src/sdl_input.cxx): UP=0 DOWN=1 LEFT=2 RIGHT=3 A=4 B=5 C=6 X=7 Y=8
Z=9 L=10 R=11 SHIFT=12. -->
<div id="keypad">
<div id="dpad">
<button id="btn-up" type="button" data-key="0" aria-label="Up">▲</button>
<button id="btn-left" type="button" data-key="2" aria-label="Left">◀</button>
<button id="btn-right" type="button" data-key="3" aria-label="Right">▶</button>
<button id="btn-down" type="button" data-key="1" aria-label="Down">▼</button>
</div>
<div id="actions">
<div id="shoulders">
<button type="button" data-key="10" aria-label="L button">L</button>
<button type="button" data-key="11" aria-label="R button">R</button>
<button type="button" data-key="12" aria-label="Dash (Shift)">Dash</button>
</div>
<div id="face">
<button type="button" data-key="5" aria-label="Cancel (B)">Cancel</button>
<button type="button" data-key="6" aria-label="OK (C)">OK</button>
</div>
<div id="extras">
<button type="button" data-key="4" aria-label="A button">A</button>
<button type="button" data-key="7" aria-label="X button">X</button>
<button type="button" data-key="8" aria-label="Y button">Y</button>
<button type="button" data-key="9" aria-label="Z button">Z</button>
</div>
</div>
</div>
<p class="hint">Arrows / WASD to move · Z / Enter confirm · X / Esc cancel · click the canvas to give it focus</p>
</div>
<!-- Unhidden by showCrash() when the engine dies or the page throws. The
report inside is what a bug report needs, ready to copy in one click. -->
<div class="panel" id="crash" hidden>
<h2>The game hit an error</h2>
<p class="hint">
Copy the report below into a bug report — it carries the error and its
backtrace, which build this is, which project was loaded and the log
leading up to the failure. Nothing else is collected.
</p>
<div class="row">
<button type="button" id="copyReport">Copy error report</button>
<button type="button" id="downloadReport" class="secondary">Download</button>
<a href="https://github.com/take-cheeze/rpg-maker-clone/issues/new" target="_blank" rel="noopener" id="issueLink">Open an issue ↗</a>
<span class="copied" id="copyNote"></span>
</div>
<pre id="crashText"></pre>
</div>
<details>
<summary>Runtime log</summary>
<pre id="log"></pre>
<!-- The same report, for a problem that did not crash the engine (a wrong
picture, silence where there should be music): there is nothing to
paste from a browser console most players never open. -->
<div class="row" style="margin-top:8px">
<button type="button" id="copyDiagnostics" class="secondary">Copy diagnostics</button>
<span class="copied" id="diagNote"></span>
</div>
</details>
<script type="text/javascript">
var statusEl = document.getElementById('status');
var logEl = document.getElementById('log');
var loaderEl = document.getElementById('loader');
var loadBtn = document.getElementById('load');
var urlInput = document.getElementById('url');
var proxyInput = document.getElementById('proxy');
var fileInput = document.getElementById('file');
var nocacheInput = document.getElementById('nocache');
var clearBtn = document.getElementById('clearcache');
// --- Remember the last URL/proxy across reloads -----------------------
// Precedence: the ?url=/&proxy= query string (a shareable, bookmarkable
// link) wins, and a query-string url auto-loads once the runtime is up.
// Otherwise the last values typed are restored from localStorage so they
// survive a plain reload. Whatever is loaded is written back into both, so
// the address bar always reflects (and can share) the current project.
var LS_URL = 'rpg2k.lastUrl';
var LS_PROXY = 'rpg2k.lastProxy';
function lsGet(k) { try { return localStorage.getItem(k) || ''; } catch (e) { return ''; } }
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
var qs = new URLSearchParams(location.search);
var qsUrl = (qs.get('url') || '').trim();
var qsProxy = qs.get('proxy');
urlInput.value = qsUrl || lsGet(LS_URL);
proxyInput.value = qsProxy !== null ? qsProxy : lsGet(LS_PROXY);
// Reflect the given url/proxy into the address bar without reloading, so a
// refresh or a copied link carries them.
function syncQuery(url, proxy) {
var p = new URLSearchParams();
if (url) p.set('url', url);
if (proxy) p.set('proxy', proxy);
var s = p.toString();
try { history.replaceState(null, '', s ? '?' + s : location.pathname); } catch (e) {}
}
function setStatus(text, kind) {
statusEl.textContent = text;
statusEl.className = kind || '';
}
// --- Error reporting ---------------------------------------------------
// A crash in the browser leaves a frozen canvas and, at best, a wall of
// text in a panel nobody opens — so nothing useful ever reaches a bug
// report. The engine prints one self-contained report (the error, its
// backtrace, the build, the project, the runtime log tail) between the
// markers below; the page watches its log for them, adds what only the
// browser knows, and offers the result as one click of "Copy error
// report". Keep the markers in sync with include/error_dump.hxx.
var ERROR_DUMP_BEGIN = '----- BEGIN RPG MAKER CLONE ERROR REPORT -----';
var ERROR_DUMP_END = '----- END RPG MAKER CLONE ERROR REPORT -----';
var LOG_TAIL_LINES = 200;
var crashEl = document.getElementById('crash');
var crashTextEl = document.getElementById('crashText');
var copyNoteEl = document.getElementById('copyNote');
var diagNoteEl = document.getElementById('diagNote');
var logLines = []; // the page's own bounded log tail
var engineReport = ''; // the last report the engine printed, if any
var dumpLines = null; // non-null while one is being read off the log
var lastReport = ''; // what the buttons copy
var currentProject = '';
function noteLogLine(text) {
var line = text.replace(/\s+$/, '');
if (line === ERROR_DUMP_BEGIN) { dumpLines = []; return; }
if (dumpLines) {
if (line !== ERROR_DUMP_END) { dumpLines.push(text); return; }
engineReport = dumpLines.join('\n');
dumpLines = null;
showCrash();
return;
}
// The report's own lines are kept out of the tail below: they are
// already in engineReport, and repeating them would bury the log.
logLines.push(text);
if (logLines.length > LOG_TAIL_LINES) logLines.shift();
}
function log(text) {
logEl.textContent += text + '\n';
logEl.scrollTop = logEl.scrollHeight;
String(text).split('\n').forEach(noteLogLine);
}
function fenced(text) {
return '```\n' + text + '\n```\n\n';
}
// The whole report: whatever the engine produced (absent when the failure
// was in the page itself), then the browser side of the story.
function buildReport(kind, detail) {
var out = engineReport
? engineReport.replace(/\s*$/, '') + '\n\n'
: '# RPG Maker Clone error report\n\n';
out += '## Browser\n\n';
out += '- page: ' + location.href + '\n';
out += '- user agent: ' + navigator.userAgent + '\n';
out += '- project: ' + (currentProject || '(none loaded)') + '\n';
out += '- when: ' + new Date().toISOString() + '\n\n';
if (detail) {
out += '### ' + kind + '\n\n' + fenced(detail);
}
if (logLines.length) {
out += '## Page log (last ' + logLines.length + ' lines)\n\n';
out += fenced(logLines.join('\n'));
}
return out;
}
function showCrash(kind, detail) {
lastReport = buildReport(kind, detail);
crashTextEl.textContent = lastReport;
crashEl.hidden = false;
setStatus('The game hit an error — copy the report below.', 'error');
crashEl.scrollIntoView({ block: 'nearest' });
}
async function copyText(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (e) {
// Blocked (an insecure origin, or a browser that wants the older
// API); fall through to the selection-based copy, which works there.
}
var ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
var ok = false;
try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
document.body.removeChild(ta);
return ok;
}
function note(el, ok) {
el.textContent = ok
? 'Copied.'
: 'Could not reach the clipboard — select the report and copy it.';
setTimeout(function () { el.textContent = ''; }, 5000);
}
document.getElementById('copyReport').addEventListener('click', async function () {
note(copyNoteEl, await copyText(lastReport || buildReport()));
});
document.getElementById('downloadReport').addEventListener('click', function () {
var blob = new Blob([lastReport || buildReport()], { type: 'text/markdown' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'rpg-maker-clone-error-report.md';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function () { URL.revokeObjectURL(url); }, 0);
});
// Not everything worth reporting kills the engine: a wrong picture or a
// silent BGM leaves the game running with the reason sitting in the log.
document.getElementById('copyDiagnostics').addEventListener('click', async function () {
note(diagNoteEl, await copyText(buildReport()));
});
// A wasm trap, or a bug in this page, never reaches the engine's report
// path — it surfaces as a JS error. Report those the same way.
window.addEventListener('error', function (e) {
var err = e.error;
showCrash('Page error', err && err.stack ? err.stack : (e.message || String(e)));
});
window.addEventListener('unhandledrejection', function (e) {
var r = e.reason;
showCrash('Unhandled promise rejection', r && r.stack ? r.stack : String(r));
});
// --- Save persistence --
// Every maker this build supports writes saves as plain files relative
// to the game directory (/game here) -- the same place the loader just
// unzipped the whole project into, and which is rebuilt from scratch on
// every page load. Mounting IDBFS directly on /game would persist every
// asset the project ships (art, music, database files) right along with
// the save, forever growing the origin's IndexedDB quota for no benefit
// -- the archive itself is already cached separately (Cache Storage,
// above). Instead only files shaped like a save are mirrored into a
// side directory backed by IDBFS, keyed by the loaded project so two
// games' saves never collide, and copied back onto /game right before
// that project starts.
var PERSIST_ROOT = '/persist';
// Root-level filenames only: RPG2000/2003 (this engine's own Save<N>.lsd,
// see #lsd_path in mruby-rpg2k/mrblib/main.rb), RPG Maker XP
// (Save<N>.rxdata) and VX/VX Ace (Save<N>.rvdata[2]) -- all written by
// stock scripts via bare `File.open("SaveN.xxx", "wb")`, resolved
// against the game directory (src/main.cxx's chdir for RGSS games).
var SAVE_NAME_PATTERNS = [
/^save\d+\.mrb$/i,
/^save\d+\.lsd$/i,
/^save\d+\.rxdata$/i,
/^save\d+\.rvdata2?$/i,
];
// RPG Maker MV/MZ funnels its whole localStorage-backed save store
// through this one fixed path (mruby-mvjs/src/mvjs.cxx).
var MV_SAVE_PATH = 'save/websave.json';
// Whether `rel` (a /game-relative path) is save data worth persisting.
// Root-level names are matched by extension/shape only -- a project's
// own asset files never sit at the game root with these shapes (e.g.
// an XP project's database .rxdata files live under Data/, filtered
// out here by the "no extra slash" check).
function isSavePath(rel) {
if (rel === MV_SAVE_PATH) return true;
if (rel.indexOf('/') >= 0) return false;
return SAVE_NAME_PATTERNS.some(function (re) { return re.test(rel); });
}
// A filesystem-safe key for the currently loaded project, so unrelated
// games don't share a save slot. Derived from `currentProject` (the
// loaded zip's URL/repo text or local filename, already tracked above
// for the crash report) -- stable across reloads for the common
// bookmarked-?url= flow, though two different projects sharing a bare
// filename will collide; a real per-project id is future work.
function projectSlug() {
var raw = (currentProject || 'game').toLowerCase();
return raw.replace(/[^a-z0-9._-]+/g, '_').slice(0, 80) || 'game';
}
function persistDir() { return PERSIST_ROOT + '/' + projectSlug(); }
// Resolved once the runtime's FS exists and IDBFS has populated /persist
// from IndexedDB; restoreSaves/mirrorSave both wait on it so neither
// races the initial read. Falls back to "no persistence" rather than
// rejecting -- a browser without IndexedDB (private-mode Safari, some
// embedded webviews) should still run the game, just without it. Built
// lazily by initPersistence() (called from onRuntimeInitialized below):
// Module.FS does not exist until the runtime is up, so this cannot run
// at script-evaluation time the way the rest of this section can.
var persistReady = null;
// Emscripten's IDBFS.syncfs has no re-entrancy guard of its own: two
// overlapping calls race the same IndexedDB reconcile, and a callback
// can simply never fire -- which reads as the page hanging. RPG Maker
// MV/MZ's save flow writes save/websave.json once per localStorage key
// it persists (several in a row for one "save"), so mirrorSave's own
// debounce below is not enough by itself: a slow flush can still be in
// flight when the next debounce elapses. Every syncfs call in
// this file -- the initial populate and every later flush -- goes
// through this one queue instead, so at most one is ever in flight;
// a call made while another is running is coalesced into a single
// follow-up run rather than started concurrently.
var syncInFlight = false;
var syncQueued = false;
function runSyncfs(FS, populate, callback) {
if (syncInFlight) {
syncQueued = true;
if (callback) callback(null);
return;
}
syncInFlight = true;
FS.syncfs(populate, function (err) {
syncInFlight = false;
if (callback) callback(err);
if (syncQueued) {
syncQueued = false;
runSyncfs(FS, false, null);
}
});
}
function initPersistence() {
persistReady = new Promise(function (resolve) {
try {
var FS = Module.FS;
FS.mkdir(PERSIST_ROOT);
FS.mount(FS.filesystems.IDBFS, {}, PERSIST_ROOT);
runSyncfs(FS, true, function (err) {
if (err) log('(save sync: could not read saved data from IndexedDB: ' + err.message + ')');
resolve(true);
});
} catch (e) {
log('(save persistence unavailable in this browser: ' + (e && e.message ? e.message : e) + ')');
resolve(false);
}
});
// Any write under /game shaped like a save gets mirrored into IDBFS
// (see mirrorSave below); everything else -- the project's own
// assets, written once by the loader -- is left alone.
Module.FS.trackingDelegate = Module.FS.trackingDelegate || {};
Module.FS.trackingDelegate.onCloseFile = function (path) {
if (restoring || mirroring || path.indexOf('/game/') !== 0) return;
var rel = path.slice('/game/'.length);
if (isSavePath(rel)) mirrorSave(rel);
};
}
// Guards mirrorSave against re-persisting the very files restoreSaves
// just wrote into /game -- FS.writeFile closes the file same as any
// other write, so it fires the trackingDelegate hook below too.
var restoring = false;
// Guards against mirrorSave re-triggering itself: FS.readFile('/game/'
// + rel) below closes that same file once it's done reading, which
// fires onCloseFile for that very save-shaped path again. Since
// persistReady is already resolved by the time saves happen, the
// resulting mirrorSave() call chains a new already-resolved-promise
// microtask immediately rather than waiting on anything -- with no
// guard, that recursion never bottoms out and the microtask queue
// never drains, freezing the tab solid on every single save. (This is
// a different bug from the syncfs re-entrancy runSyncfs guards
// against; that one only covers overlapping IDBFS.syncfs calls, not
// this self-triggering read.)
var mirroring = false;
// Copy back whatever was persisted for this project, right before it
// starts. Only two shapes exist (see isSavePath): root-level save files,
// and MV/MZ's single nested save/websave.json.
async function restoreSaves() {
var ok = await persistReady;
if (!ok) return;
var FS = Module.FS;
var dir = persistDir();
var entries;
try { entries = FS.readdir(dir); } catch (e) { return; } // nothing saved yet
restoring = true;
try {
var restored = 0;
entries.forEach(function (name) {
if (name === '.' || name === '..') return;
var src = dir + '/' + name;
if (FS.isDir(FS.stat(src).mode)) {
// Only 'save/' (MV/MZ) ever nests; copy its one file straight
// through rather than a general recursive walk.
var sub;
try { sub = FS.readdir(src); } catch (e) { sub = []; }
sub.forEach(function (leaf) {
if (leaf === '.' || leaf === '..') return;
try {
FS.mkdirTree('/game/' + name);
FS.writeFile('/game/' + name + '/' + leaf, FS.readFile(src + '/' + leaf));
restored++;
} catch (e) { /* leave that one file absent */ }
});
} else {
try {
FS.writeFile('/game/' + name, FS.readFile(src));
restored++;
} catch (e) { /* leave that one file absent */ }
}
});
if (restored) log('Restored ' + restored + ' saved file(s) from browser storage.');
} finally {
restoring = false;
}
}
// Mirror one just-written /game save file into the IDBFS-backed persist
// dir and (debounced, so a burst of saves -- e.g. several slots written in a
// row -- costs one flush) push it to IndexedDB.
var syncTimer = null;
function mirrorSave(rel) {
persistReady.then(function (ok) {
if (!ok) return;
var FS = Module.FS;
var dst = persistDir() + '/' + rel;
var dstDir = dst.slice(0, dst.lastIndexOf('/'));
mirroring = true;
try {
FS.mkdirTree(dstDir);
FS.writeFile(dst, FS.readFile('/game/' + rel));
} catch (e) {
log('(could not stage "' + rel + '" for browser storage: ' + (e && e.message ? e.message : e) + ')');
return;
} finally {
mirroring = false;
}
if (syncTimer) clearTimeout(syncTimer);
syncTimer = setTimeout(function () {
syncTimer = null;
runSyncfs(FS, false, function (err) {
if (err) log('(save sync failed: ' + err.message + ')');
});
}, 500);
});
}
// Best-effort: flush a pending debounced sync immediately if the tab is
// going away. syncfs is inherently asynchronous (IndexedDB), so this
// cannot guarantee completion before the page actually unloads -- it
// only gives the in-flight write a head start.
window.addEventListener('pagehide', function () {
if (!syncTimer) return;
clearTimeout(syncTimer);
syncTimer = null;
try { runSyncfs(Module.FS, false, function () {}); } catch (e) {}
});
// --- Emscripten module wiring -----------------------------------------
var runtimeReady = false;
var Module = {
canvas: (function () {
var canvas = document.getElementById('canvas');
// Surface a lost WebGL context instead of letting the page silently
// wedge.
canvas.addEventListener('webglcontextlost', function (e) {
setStatus('WebGL context lost — reload the page to continue.', 'error');
e.preventDefault();
}, false);
return canvas;
})(),
print: function (t) { log(t); },
printErr: function (t) { log(t); },
// The runtime gave up (an assertion, an out-of-memory, a trap). It
// prints no report of its own from here, so build one from the page.
onAbort: function (what) { showCrash('Runtime abort', String(what)); },
onRuntimeInitialized: function () {
runtimeReady = true;
initPersistence();
loadBtn.disabled = false;
setStatus('Ready — choose a project to load.');
// Offer the one-click preset when a sample project is baked into the
// virtual filesystem at /game_sample (WASM_SAMPLE_DIR in the build).
try {
if (Module.FS.analyzePath('/game_sample').exists) {
document.getElementById('presetRow').style.display = '';
}
} catch (e) { /* no sample bundled */ }
// A shared/bookmarked ?url= link loads on its own.
if (qsUrl) loadBtn.click();
},
};
// --- Minimal ZIP reader (central directory + DecompressionStream) -----
async function inflateRaw(bytes) {
var ds = new DecompressionStream('deflate-raw');
var stream = new Response(bytes).body.pipeThrough(ds);
var ab = await new Response(stream).arrayBuffer();
return new Uint8Array(ab);
}
function decodeName(bytes, flag) {
// Bit 11 of the general-purpose flag marks UTF-8 names; otherwise RPG
// Maker zips are typically Shift-JIS. Fall back to UTF-8 if the label is
// unknown.
var utf8 = (flag & 0x800) !== 0;
try {
return new TextDecoder(utf8 ? 'utf-8' : 'shift_jis', { fatal: false }).decode(bytes);
} catch (e) {
return new TextDecoder('utf-8', { fatal: false }).decode(bytes);
}
}
async function unzip(arrayBuffer) {
if (typeof DecompressionStream === 'undefined') {
throw new Error('This browser lacks DecompressionStream; please use a current Chrome, Firefox or Safari.');
}
var bytes = new Uint8Array(arrayBuffer);
var dv = new DataView(arrayBuffer);
// Locate the End Of Central Directory record by scanning back from the
// end (its trailing comment is almost always empty, but allow up to
// 64 KiB).
var eocd = -1;
var minPos = Math.max(0, bytes.length - (22 + 0xffff));
for (var i = bytes.length - 22; i >= minPos; i--) {
if (dv.getUint32(i, true) === 0x06054b50) { eocd = i; break; }
}
if (eocd < 0) throw new Error('Not a ZIP archive (no end-of-central-directory record).');
var count = dv.getUint16(eocd + 10, true);
var cd = dv.getUint32(eocd + 16, true);
if (cd === 0xffffffff || count === 0xffff) {
throw new Error('ZIP64 archives are not supported; please repackage the project as a standard zip.');
}
var files = [];
for (var n = 0; n < count; n++) {
if (dv.getUint32(cd, true) !== 0x02014b50) throw new Error('Corrupt ZIP central directory.');
var flag = dv.getUint16(cd + 8, true);
var method = dv.getUint16(cd + 10, true);
var compSize = dv.getUint32(cd + 20, true);
var nameLen = dv.getUint16(cd + 28, true);
var extraLen = dv.getUint16(cd + 30, true);
var commentLen = dv.getUint16(cd + 32, true);
var localOff = dv.getUint32(cd + 42, true);
var name = decodeName(bytes.subarray(cd + 46, cd + 46 + nameLen), flag);
cd += 46 + nameLen + extraLen + commentLen;
if (name.endsWith('/')) continue; // directory entry
if (dv.getUint32(localOff, true) !== 0x04034b50) {
throw new Error('Corrupt ZIP local header for ' + name);
}
// The local header may carry a different extra field than the central
// one.
var lNameLen = dv.getUint16(localOff + 26, true);
var lExtraLen = dv.getUint16(localOff + 28, true);
var dataStart = localOff + 30 + lNameLen + lExtraLen;
var comp = bytes.subarray(dataStart, dataStart + compSize);
var data;
if (method === 0) data = comp.slice(); // stored
else if (method === 8) data = await inflateRaw(comp); // deflate
else throw new Error('Unsupported ZIP compression method ' + method + ' in ' + name);
files.push({ name: name.replace(/\\/g, '/'), data: data });
}
return files;
}
// Find the directory prefix that contains the project's marker file, so a
// project wrapped in a top-level folder (GitHub archives always are)
// mounts its contents directly at /game.
function findRootPrefix(files) {
// RPG2k/XP: a marker file sits at the project root, so the prefix is
// that file's directory.
var markers = ['rpg_rt.ldb', 'game.ini'];
for (var m = 0; m < markers.length; m++) {
for (var i = 0; i < files.length; i++) {
var parts = files[i].name.split('/');
if (parts[parts.length - 1].toLowerCase() === markers[m]) {
var slash = files[i].name.lastIndexOf('/');
return slash < 0 ? '' : files[i].name.slice(0, slash + 1);
}
}
}
// RPG Maker MV: the marker is nested (js/rpg_core.js), so the project
// root is everything before that path segment.
for (var j = 0; j < files.length; j++) {
var lname = files[j].name.toLowerCase();
var idx = lname.indexOf('js/rpg_core.js');
if (idx >= 0 && (idx === 0 || lname.charAt(idx - 1) === '/')) {
return files[j].name.slice(0, idx);
}
}
return null;
}
async function mountAndStart(files) {
var prefix = findRootPrefix(files);
if (prefix === null) {
throw new Error('No RPG_RT.ldb, Game.ini, or js/rpg_core.js in the archive — is this an RPG Maker 2000/2003, XP, or MV project?');
}
var FS = Module.FS;
FS.mkdirTree('/game');
var written = 0;
var lower = prefix.toLowerCase();
for (var i = 0; i < files.length; i++) {
var name = files[i].name;
if (name.toLowerCase().indexOf(lower) !== 0) continue; // outside project root
var rel = name.slice(prefix.length);
if (!rel) continue;
var dest = '/game/' + rel;
var dir = dest.slice(0, dest.lastIndexOf('/'));
if (dir && dir !== '/game') FS.mkdirTree(dir);
FS.writeFile(dest, files[i].data);
written++;
}
log('Mounted ' + written + ' file(s) under /game (root: "' + (prefix || './') + '").');
// Bring back this project's own saved data (if any) before it starts,
// so Continue/Load sees whatever the last session left behind.
await restoreSaves();
var rc = Module.ccall('rpg_start_game', 'number', [], []);
if (rc !== 0) {
throw new Error('The runtime could not start the project (see the runtime log for details).');
}
loaderEl.hidden = true;
setStatus('');
Module.canvas.focus();
}
// Start the sample project baked into the FS at /game_sample: copy it to
// /game (where rpg_start_game looks) and run it. No download needed.
async function startBundledSample() {
var FS = Module.FS;
function copyTree(src, dst) {
FS.mkdirTree(dst);
var entries = FS.readdir(src);
for (var i = 0; i < entries.length; i++) {
var name = entries[i];
if (name === '.' || name === '..') continue;
var s = src + '/' + name, d = dst + '/' + name;
if (FS.isDir(FS.stat(s).mode)) copyTree(s, d);
else FS.writeFile(d, FS.readFile(s));
}
}
try {
setStatus('Starting the bundled sample…');
currentProject = 'the bundled sample';
copyTree('/game_sample', '/game');
await restoreSaves();
var rc = Module.ccall('rpg_start_game', 'number', [], []);
if (rc !== 0) throw new Error('runtime returned ' + rc);
loaderEl.hidden = true;
setStatus('');
Module.canvas.focus();
} catch (e) {
setStatus('Could not start the bundled sample: ' + e.message, 'error');
}
}
document.getElementById('loadSample').addEventListener('click', startBundledSample);
// --- URL resolution ---------------------------------------------------
// Turn a GitHub repo reference into its codeload zip URL. Accepts
// "owner/repo", "owner/repo@ref", or a github.com URL (optionally
// /tree/ref). Anything else (already a .zip or arbitrary URL) is returned
// unchanged.
function resolveUrl(input) {
input = input.trim();
var m;
if ((m = input.match(/github\.com\/([^\/\s]+)\/([^\/\s]+?)(?:\.git)?(?:\/tree\/([^\/\s]+))?\/?$/i))) {
return 'https://codeload.github.com/' + m[1] + '/' + m[2] + '/zip/' + (m[3] || 'HEAD');
}
if ((m = input.match(/^([\w.\-]+)\/([\w.\-]+?)(?:@([\w.\-\/]+))?$/))) {
return 'https://codeload.github.com/' + m[1] + '/' + m[2] + '/zip/' + (m[3] || 'HEAD');
}
return input;
}
// --- Archive cache (Cache Storage, keyed by the resolved URL) ----------
// Downloaded archives are kept so re-loading the same URL or GitHub repo
// skips the network. The bytes are stored inside a locally-built Response,
// which sidesteps the restriction that Cache Storage will not accept
// opaque cross-origin responses. Available in secure contexts, which
// include http://localhost — where the project is normally served.
var ARCHIVE_CACHE = 'rpg2k-archives-v1';
var cacheAvailable = typeof caches !== 'undefined';
async function cacheGet(key) {
if (!cacheAvailable) return null;
try {
var c = await caches.open(ARCHIVE_CACHE);
var r = await c.match(key);
return r ? await r.arrayBuffer() : null;
} catch (e) {
return null;
}
}
async function cachePut(key, buf) {
if (!cacheAvailable) return;
try {
var c = await caches.open(ARCHIVE_CACHE);
await c.put(key, new Response(buf));
} catch (e) {