-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathExportTsProbe.cpp
More file actions
2968 lines (2810 loc) · 145 KB
/
Copy pathExportTsProbe.cpp
File metadata and controls
2968 lines (2810 loc) · 145 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
// libreshockwave_export_ts
//
// Director movie -> self-contained TypeScript / PixiJS project exporter.
//
// This is a read-only probe over the parsed Director model: it loads a movie
// (reusing the RenderProbe loading path), renders every frame through the C++
// pipeline, and serializes the result into a TS project sourced exclusively
// from this repository's runtime/ tree. It adds no C++ public API and
// changes no renderer behavior; it only reads the parsed model and emits data.
//
// Stage 2 scope: bitmap sprites with COPY / TRANSPARENT / BLEND inks, baked by
// the C++ SpriteBaker and composited by SoftwareFrameRenderer. The exported TS
// runtime re-implements compositing in TS and is checked for parity against the
// C++ reference frames dumped here under assets/reference/.
//
// Usage:
// libreshockwave_export_ts <movie> [--out <dir>] [--frames <N>] [--no-preload-casts]
#include <algorithm>
#include <array>
#include <cctype>
#include <cstdint>
#include <cstdlib>
#include <exception>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <map>
#include <sstream>
#include <stdexcept>
#include <string>
#include <string_view>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include "libreshockwave/DirectorFile.hpp"
#include "libreshockwave/bitmap/Bitmap.hpp"
#include "libreshockwave/chunks/CastChunk.hpp"
#include "libreshockwave/chunks/CastMemberChunk.hpp"
#include "libreshockwave/chunks/FrameLabelsChunk.hpp"
#include "libreshockwave/cast/MemberType.hpp"
#include "libreshockwave/chunks/ScriptChunk.hpp"
#include "libreshockwave/chunks/ScriptNamesChunk.hpp"
#include "libreshockwave/chunks/SoundChunk.hpp"
#include "libreshockwave/chunks/TextChunk.hpp"
#include "libreshockwave/cast/CastMember.hpp"
#include "libreshockwave/lingo/decompiler/LingoDecompiler.hpp"
#include "libreshockwave/player/audio/SoundManager.hpp"
#include "libreshockwave/player/cast/CastLib.hpp"
#include "libreshockwave/player/Player.hpp"
#include "libreshockwave/player/behavior/BehaviorInstance.hpp"
#include "libreshockwave/player/behavior/BehaviorManager.hpp"
#include "libreshockwave/player/render/pipeline/FrameSnapshot.hpp"
#include "libreshockwave/player/render/pipeline/RenderSprite.hpp"
#include "libreshockwave/player/score/ScoreBehaviorRef.hpp"
#include "libreshockwave/player/score/ScoreNavigator.hpp"
#include "libreshockwave/player/score/SpriteSpan.hpp"
#include "libreshockwave/util/FileUtil.hpp"
namespace fs = std::filesystem;
namespace ls = libreshockwave;
namespace rsp = libreshockwave::player::render::pipeline;
namespace {
// --- file IO helpers (mirrors RenderProbe's anonymous-namespace ones) --------
std::vector<std::uint8_t> readFile(const fs::path& path) {
std::ifstream input(path, std::ios::binary);
if (!input) {
throw std::runtime_error("Unable to open file: " + path.string());
}
input.seekg(0, std::ios::end);
const auto end = input.tellg();
if (end == std::ifstream::pos_type(-1)) {
throw std::runtime_error("Unable to determine file size: " + path.string());
}
input.seekg(0, std::ios::beg);
std::vector<std::uint8_t> data(static_cast<std::size_t>(end));
if (!data.empty()) {
input.read(reinterpret_cast<char*>(data.data()), static_cast<std::streamsize>(data.size()));
if (!input) {
throw std::runtime_error("Unable to read complete file: " + path.string());
}
}
return data;
}
void logMemoryUsage(const std::string& label) {
std::ifstream status("/proc/self/status");
if (!status) {
return;
}
std::string line;
while (std::getline(status, line)) {
if (line.rfind("VmRSS:", 0) == 0) {
std::cerr << "export_ts: memory [" << label << "] " << line.substr(line.find_first_not_of(" \t", 6)) << "\n";
break;
}
}
}
std::string lowerCopy(std::string_view value) {
std::string lowered(value);
std::transform(lowered.begin(), lowered.end(), lowered.begin(), [](unsigned char ch) {
return static_cast<char>(std::tolower(ch));
});
return lowered;
}
bool hasDirectorContainerHeader(const std::vector<std::uint8_t>& data) {
if (data.size() < 4) {
return false;
}
const std::string_view header(reinterpret_cast<const char*>(data.data()), 4);
return header == "RIFX" || header == "XFIR" || header == "RIFF" || header == "FFIR";
}
// Write an ARGB Uint32 pixel buffer out as raw RGBA bytes (R,G,B,A per pixel).
// The TS runtime decodes this back into a Uint32Array by reading bytes 0..3 of
// each pixel as R,G,B,A and repacking to ARGB. Raw RGBA is dependency-free and
// lossless, which keeps the differential harness exact.
void writeRgba(const fs::path& path, const std::vector<std::uint32_t>& pixels) {
std::ofstream out(path, std::ios::binary | std::ios::trunc);
if (!out) {
throw std::runtime_error("Unable to write: " + path.string());
}
std::string bytes;
bytes.reserve(pixels.size() * 4);
for (const std::uint32_t p : pixels) {
const std::uint8_t r = static_cast<std::uint8_t>((p >> 16) & 0xff);
const std::uint8_t g = static_cast<std::uint8_t>((p >> 8) & 0xff);
const std::uint8_t b = static_cast<std::uint8_t>(p & 0xff);
const std::uint8_t a = static_cast<std::uint8_t>((p >> 24) & 0xff);
bytes.push_back(static_cast<char>(r));
bytes.push_back(static_cast<char>(g));
bytes.push_back(static_cast<char>(b));
bytes.push_back(static_cast<char>(a));
}
out.write(bytes.data(), static_cast<std::streamsize>(bytes.size()));
if (!out) {
throw std::runtime_error("Unable to write complete file: " + path.string());
}
}
// FNV-1a 64-bit over the raw bytes of an ARGB pixel vector. Used to content-address baked
// bitmaps: the same cast member can bake to different pixels across frames (animated or
// script-modified images), so deduping by (memberId, size) collides distinct content.
// Content-addressing dedups genuinely-identical bitmaps (the common static case) while
// giving different content its own file — the parity oracle requires the exported baked
// bitmap to match what the C++ reference frame actually composited, per frame.
std::uint64_t fnv1a64(const std::vector<std::uint32_t>& pixels) {
constexpr std::uint64_t offset = 14695981039346656037ULL;
constexpr std::uint64_t prime = 1099511628211ULL;
std::uint64_t hash = offset;
const auto* bytes = reinterpret_cast<const std::uint8_t*>(pixels.data());
const std::size_t count = pixels.size() * sizeof(std::uint32_t);
for (std::size_t i = 0; i < count; ++i) {
hash ^= bytes[i];
hash *= prime;
}
return hash;
}
// Recursively copy a source tree into a destination, skipping build artifacts and deps
// (node_modules / dist / .tsbuildinfo) so the exported project is clean. Mirrors what a
// developer would commit from the runtime source tree.
void copyTree(const fs::path& src, const fs::path& dst) {
if (!fs::exists(src)) {
throw std::runtime_error("Template source not found: " + src.string());
}
std::error_code ec;
for (auto it = fs::recursive_directory_iterator(src, fs::directory_options::skip_permission_denied, ec);
it != fs::recursive_directory_iterator(); ++it) {
const auto& entry = *it;
const auto rel = fs::relative(entry.path(), src, ec);
// Skip dependency and build-output subtrees.
const std::string leaf = entry.path().filename().string();
if (entry.is_directory() && (leaf == "node_modules" || leaf == "dist" || leaf == ".git")) {
it.disable_recursion_pending();
continue;
}
if (leaf == ".tsbuildinfo" || entry.path().extension() == ".tsbuildinfo") {
continue;
}
const auto target = dst / rel;
if (entry.is_directory()) {
fs::create_directories(target);
} else if (entry.is_regular_file()) {
fs::create_directories(target.parent_path());
fs::copy_file(entry.path(), target, fs::copy_options::overwrite_existing, ec);
if (ec) {
throw std::runtime_error("Unable to copy " + entry.path().string() + " -> " + target.string()
+ ": " + ec.message());
}
}
}
}
// Assemble the runnable TS project from the single runtime/ source of truth. Project
// scaffolding lives under runtime/project/, while the reusable implementation lives under
// runtime/src/ and is copied verbatim to the exported src/runtime/ directory.
void copyProjectSkeleton(const fs::path& outDir) {
#ifndef LIBRESHOCKWAVE_TS_RUNTIME_DIR
#define LIBRESHOCKWAVE_TS_RUNTIME_DIR ""
#endif
#ifndef LIBRESHOCKWAVE_FONT_RESOURCE_DIR
#define LIBRESHOCKWAVE_FONT_RESOURCE_DIR ""
#endif
const fs::path runtimeRoot = LIBRESHOCKWAVE_TS_RUNTIME_DIR;
const fs::path fontRoot = LIBRESHOCKWAVE_FONT_RESOURCE_DIR;
#undef LIBRESHOCKWAVE_TS_RUNTIME_DIR
#undef LIBRESHOCKWAVE_FONT_RESOURCE_DIR
if (runtimeRoot.empty() || !fs::exists(runtimeRoot)) {
throw std::runtime_error("Runtime source not found: " + runtimeRoot.string());
}
copyTree(runtimeRoot / "project", outDir);
fs::create_directories(outDir / "src" / "runtime");
copyTree(runtimeRoot / "src", outDir / "src" / "runtime");
if (!fontRoot.empty()) {
const fs::path volterRoot = fontRoot / "volter";
const fs::path fontOut = outDir / "public" / "fonts";
fs::create_directories(fontOut);
fs::copy_file(volterRoot / "volter.ttf", fontOut / "volter.ttf",
fs::copy_options::overwrite_existing);
fs::copy_file(volterRoot / "volter_bold.ttf", fontOut / "volter_bold.ttf",
fs::copy_options::overwrite_existing);
}
}
// --- pre-bake cast member assets ---------------------------------------------
// Director sprites can be reassigned to arbitrary cast members at runtime via
// Lingo (e.g. `set the member of sprite 5 to "chair_1"`). The static exporter
// only bakes the members visible on score frames, so we pre-bake every member
// that has renderable image data and register it by name/number. Film-loops are
// baked for every internal sub-frame so the TS runtime can animate them.
namespace {
rsp::SpriteType spriteTypeForMemberType(::libreshockwave::cast::MemberType type) {
switch (type) {
case ::libreshockwave::cast::MemberType::Bitmap:
case ::libreshockwave::cast::MemberType::Picture:
return rsp::SpriteType::Bitmap;
case ::libreshockwave::cast::MemberType::FilmLoop:
return rsp::SpriteType::FilmLoop;
case ::libreshockwave::cast::MemberType::Text:
case ::libreshockwave::cast::MemberType::Button:
return rsp::SpriteType::Text;
case ::libreshockwave::cast::MemberType::Shape:
return rsp::SpriteType::Shape;
case ::libreshockwave::cast::MemberType::Shockwave3D:
return rsp::SpriteType::Shockwave3D;
default:
return rsp::SpriteType::Unknown;
}
}
} // namespace
// --- minimal hand-rolled JSON emission ---------------------------------------
std::string jsonEscape(std::string_view s) {
std::string out;
out.reserve(s.size() + 2);
for (const char c : s) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\b': out += "\\b"; break;
case '\f': out += "\\f"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if (static_cast<std::uint8_t>(c) < 0x20) {
std::ostringstream hex;
hex << "\\u" << std::hex << std::setw(4) << std::setfill('0')
<< static_cast<int>(static_cast<std::uint8_t>(c));
out += hex.str();
} else {
out += c;
}
break;
}
}
return out;
}
// Make a cast-member name safe to use as an asset filename: keep alphanumerics, dash, dot,
// underscore; collapse everything else to a single underscore; strip leading/trailing dots
// and spaces. Empty results fall back to a numeric id at the call site.
std::string sanitizeAssetName(std::string_view s) {
std::string out;
out.reserve(s.size());
for (const char c : s) {
if (std::isalnum(static_cast<unsigned char>(c)) || c == '-' || c == '_' || c == '.') {
out += static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
} else if (!out.empty() && out.back() != '_') {
out += '_';
}
}
while (!out.empty() && (out.back() == '.' || out.back() == '_' || out.back() == ' ')) {
out.pop_back();
}
while (!out.empty() && (out.front() == '.' || out.front() == '_')) {
out.erase(out.begin());
}
return out;
}
// Map a ScriptChunkType to the string emitted in script metadata.
std::string scriptTypeToString(ls::chunks::ScriptChunkType t) {
switch (t) {
case ls::chunks::ScriptChunkType::Score: return "Score";
case ls::chunks::ScriptChunkType::Behavior: return "Behavior";
case ls::chunks::ScriptChunkType::MovieScript: return "MovieScript";
case ls::chunks::ScriptChunkType::Parent: return "Parent";
default: return "Unknown";
}
}
// Director system-event handler names. A handler whose name matches one of these is dispatched
// by the player on the corresponding event; everything else is a plain handler invoked from Lingo.
// Used only to tag emitted handlers with their dispatch event — it changes no behavior.
bool isLingoSystemEvent(std::string_view name) {
static const std::unordered_set<std::string_view> events = {
"prepareMovie", "prepareFrame", "enterFrame", "exitFrame", "beginSprite", "endSprite", "stepFrame",
"mouseDown", "mouseUp", "mouseEnter", "mouseLeave", "mouseWithin", "mouseUpOutside",
"rightMouseDown", "rightMouseUp", "keyDown", "keyUp", "idle", "startMovie", "stopMovie",
"stepMovie", "new", "openWindow", "closeWindow", "moveWindow", "resizeWindow",
"activateWindow", "deactivateWindow", "resume", "suspend", "cuePassed",
"beginKeyboardFocus", "endKeyboardFocus", "beginSprite"
};
return events.find(name) != events.end();
}
// Sanitize a Lingo handler/script name into a valid TS identifier fragment (used for filenames
// and quoted object keys, so it need only avoid quotes/backslashes/control chars).
std::string sanitizeTsKey(std::string_view s) {
std::string out;
out.reserve(s.size());
for (const char c : s) {
if (c == '"' || c == '\\' || static_cast<std::uint8_t>(c) < 0x20) {
out += '_';
} else {
out += c;
}
}
if (out.empty()) {
out = "_";
}
return out;
}
// Sanitize a Lingo handler name into a valid unquoted TypeScript identifier for function names.
std::string sanitizeTsIdentifier(std::string_view s) {
std::string out;
out.reserve(s.size());
for (std::size_t i = 0; i < s.size(); ++i) {
const char c = s[i];
if (std::isalnum(static_cast<unsigned char>(c)) || c == '_') {
out += c;
} else {
out += '_';
}
}
if (out.empty() || std::isdigit(static_cast<unsigned char>(out[0]))) {
out.insert(out.begin(), '_');
}
// Avoid TS reserved words and runtime helper names.
static const std::unordered_set<std::string> reserved = {
"var", "let", "const", "function", "return", "if", "else", "while", "for", "break",
"continue", "switch", "case", "default", "new", "this", "undefined", "true", "false",
"null", "void", "typeof", "in", "of", "do", "try", "catch", "finally", "throw", "with",
"delete", "instanceof",
"yield", "await", "async", "class", "extends", "super", "export", "import", "from", "as",
"interface", "type", "namespace", "module", "declare", "abstract", "implements", "private",
"protected", "public", "readonly", "static", "get", "set", "constructor", "debugger",
"enum", "never", "unknown", "any", "object", "number", "string", "boolean", "symbol",
"bigint", "me", "package", "arguments", "eval", "top",
};
if (reserved.count(out) > 0) {
out = "_" + out;
}
return out;
}
// Determine the web-server root that serves the movie so local files such as
// /gamedata/external_variables.txt can be resolved on disk. We walk up from the
// movie directory until we pass a "dcr" segment; the directory above it is treated
// as the web root (e.g. /var/www/html/dcr/r31_.../habbo.dcr -> /var/www/html).
fs::path webRootForMovie(const fs::path& moviePath) {
fs::path dir = moviePath.parent_path();
while (!dir.empty() && dir.has_filename() && dir.filename() != "dcr") {
dir = dir.parent_path();
}
if (!dir.empty()) {
return dir.parent_path();
}
return moviePath.parent_path();
}
// Parse the local external_variables.txt and return cast.entry.* names in index order.
std::vector<std::pair<int, std::string>> readExternalVariableCastEntries(const fs::path& moviePath) {
const fs::path root = webRootForMovie(moviePath);
const fs::path varPath = root / "gamedata" / "external_variables.txt";
std::vector<std::pair<int, std::string>> entries;
if (!fs::is_regular_file(varPath)) {
std::cerr << "export_ts: warning: external variables file not found: " << varPath.string() << "\n";
return entries;
}
std::ifstream in(varPath);
std::string line;
while (std::getline(in, line)) {
if (!line.empty() && line.back() == '\r') {
line.pop_back();
}
constexpr std::string_view prefix = "cast.entry.";
if (line.rfind(prefix, 0) != 0) {
continue;
}
const auto eq = line.find('=', prefix.size());
if (eq == std::string::npos) {
continue;
}
const int idx = std::atoi(line.substr(prefix.size(), eq - prefix.size()).c_str());
if (idx <= 0) {
continue;
}
std::string name = line.substr(eq + 1);
name.erase(0, name.find_first_not_of(" \t\r\n"));
const auto endPos = name.find_last_not_of(" \t\r\n");
if (endPos != std::string::npos) {
name.erase(endPos + 1);
} else {
name.clear();
}
if (!name.empty()) {
entries.emplace_back(idx, name);
}
}
std::sort(entries.begin(), entries.end(),
[](const auto& a, const auto& b) { return a.first < b.first; });
return entries;
}
// Load the core external .cct files listed in external_variables.txt into the
// empty dynamic cast slots. This makes their members and scripts available to
// the exporter even though the movie file only ships empty placeholders.
void loadCoreExternalCasts(ls::player::Player& player, const fs::path& moviePath) {
const auto entries = readExternalVariableCastEntries(moviePath);
if (entries.empty()) {
std::cerr << "export_ts: no cast.entry.* entries found; skipping core external cast load.\n";
return;
}
// Collect empty dynamic slot numbers, highest first, to match the Habbo
// runtime's getAvailableEmptyCast() behaviour (it pops from the end).
std::vector<int> emptySlots;
for (const auto& [num, castLib] : player.castLibManager().castLibs()) {
if (!castLib) {
continue;
}
const std::string name = lowerCopy(castLib->name());
if (name.find("empty") != std::string::npos) {
emptySlots.push_back(num);
}
}
std::sort(emptySlots.begin(), emptySlots.end(), std::greater<int>());
if (emptySlots.size() < entries.size()) {
std::cerr << "export_ts: warning: fewer empty slots (" << emptySlots.size()
<< ") than cast entries (" << entries.size() << ")\n";
}
const fs::path movieDir = moviePath.parent_path();
const std::size_t toLoad = std::min(entries.size(), emptySlots.size());
for (std::size_t i = 0; i < toLoad; ++i) {
const int slot = emptySlots[i];
const std::string& castName = entries[i].second;
fs::path chosen;
const fs::path cctCandidate = movieDir / (castName + ".cct");
if (fs::is_regular_file(cctCandidate)) {
chosen = cctCandidate;
} else {
const fs::path cstCandidate = movieDir / (castName + ".cst");
if (fs::is_regular_file(cstCandidate)) {
chosen = cstCandidate;
}
}
if (chosen.empty()) {
std::cerr << "export_ts: warning: external cast file not found for " << castName << "\n";
continue;
}
try {
const auto data = readFile(chosen);
if (data.empty()) {
continue;
}
if (!player.loadExternalCastFromCachedData(slot, data)) {
std::cerr << "export_ts: warning: failed to load external cast " << castName
<< " into slot " << slot << "\n";
continue;
}
if (auto castLib = player.castLibManager().getCastLib(slot)) {
if (!castLib->isLoaded()) {
castLib->load();
}
castLib->setName(castName);
}
} catch (const std::exception& e) {
std::cerr << "export_ts: warning: exception loading external cast " << castName
<< ": " << e.what() << "\n";
}
}
}
// --- exporter ----------------------------------------------------------------
struct ExportOptions {
fs::path moviePath;
fs::path outDir = "exported-movie";
int frames = 0; // 0 = all frames
bool preloadCasts = true;
bool lingoInit = true; // --no-lingo-init: skip player.play() / prepareMovieFoundation()
bool selfTestInks = false; // --self-test-inks: synthetic frames covering every InkMode
};
ExportOptions parseOptions(int argc, char** argv) {
ExportOptions options;
bool sawMovie = false;
for (int index = 1; index < argc; ++index) {
const std::string_view arg(argv[index]);
if (arg == "--help" || arg == "-h") {
std::cout << "Usage: " << argv[0]
<< " <movie> [--out <dir>] [--frames <N>] [--no-preload-casts] [--no-lingo-init]\n"
<< " " << argv[0] << " --self-test-inks [--out <dir>]\n"
<< " --out <dir> Output project directory (default: exported-movie)\n"
<< " --frames <N> Export only the first N frames (default: all)\n"
<< " --no-preload-casts Skip preloading external cast libraries\n"
<< " --no-lingo-init Skip player.play() / prepareMovieFoundation() (static export only)\n"
<< " --self-test-inks Emit synthetic frames covering every InkMode (no movie)\n";
std::exit(0);
}
if (arg == "--self-test-inks") {
options.selfTestInks = true;
continue;
}
if (arg == "--out") {
if (index + 1 >= argc) {
throw std::runtime_error("--out requires a value");
}
options.outDir = argv[++index];
continue;
}
if (arg == "--frames") {
if (index + 1 >= argc) {
throw std::runtime_error("--frames requires a value");
}
options.frames = std::atoi(argv[++index]);
if (options.frames < 0) {
throw std::runtime_error("--frames must be non-negative");
}
continue;
}
if (arg == "--no-preload-casts") {
options.preloadCasts = false;
continue;
}
if (arg == "--no-lingo-init") {
options.lingoInit = false;
continue;
}
if (arg.starts_with('-')) {
throw std::runtime_error("Unknown option: " + std::string(arg));
}
if (sawMovie) {
throw std::runtime_error("Unexpected extra positional argument: " + std::string(arg));
}
options.moviePath = std::string(arg);
sawMovie = true;
}
if (!sawMovie && !options.selfTestInks) {
throw std::runtime_error("Missing required <movie> argument (or pass --self-test-inks)");
}
return options;
}
// Per-sprite record written to score.json. Geometry + ink + blend + the asset
// reference to the baked bitmap (deduplicated by cast member).
struct SpriteRecord {
int channel;
int x, y, width, height;
int locZ;
bool visible;
std::string type;
int ink;
int blend;
bool flipH; // effective mirror: isFlipH() ^ hasDirectorHorizontalMirror()
bool flipV;
double rotation;
double skew;
bool hasBakedBitmap;
std::string bakedBitmapAsset; // relative to out dir, "" if none
int bakedWidth;
int bakedHeight;
int castMemberId = -1; // Director cast member number, or -1 if none
std::string castMemberName; // member name if the cast member has one
bool hasBehaviors = false; // true if the score channel carries a behavior script
};
struct BehaviorChannel {
int channel;
int castLib;
int castMember;
std::string scriptName; // best-effort name from the behavior member, may be empty
};
struct FrameRecord {
int frame;
int tempo = 0; // effective fps for this frame (score tempo channel, else base tempo)
std::optional<BehaviorChannel> frameScript; // frame script attached to this score frame
std::vector<SpriteRecord> sprites;
};
// A frame label / marker (in Director, the label set and the marker set are the same —
// ScoreNavigator populates both from the VWLB frame-labels chunk). Emitted so the TS
// ScorePlayer can navigate by name and report markers.
struct LabelRecord {
int frame;
std::string name;
};
// A decoded sound cast member exported as a playable audio asset. Director sound playback is
// Lingo-driven (there is no score sound channel), so the exporter ships the sound ASSETS +
// metadata; per-frame cues are not statically capturable and arrive with Lingo (Stage 7).
struct SoundRecord {
std::string name; // sanitized asset stem
std::string assetRef; // assets/sounds/<name>.<ext>
std::string format; // "wav" | "mp3"
int sampleRate = 0;
int channels = 0;
int bitsPerSample = 0;
double durationSeconds = 0.0;
std::string codec; // raw_pcm | mp3 | ima_adpcm
};
struct CastMemberRecord {
int id;
int castLib;
std::string name;
std::string type;
std::string bakedBitmapAsset; // for bitmap/film-loop/etc, if already exported
int bakedWidth = 0;
int bakedHeight = 0;
int regX = 0;
int regY = 0;
std::string text; // static text content for text/field cast members
std::vector<std::string> filmLoopFrames; // one asset per internal sub-frame
std::vector<std::uint32_t> paletteColors; // palette members only
};
namespace {
struct BakedAsset {
std::string assetRel;
int width = 0;
int height = 0;
};
BakedAsset bakeAndWriteAsset(const fs::path& outDir,
std::unordered_set<std::string>& writtenBitmaps,
const std::shared_ptr<const ls::bitmap::Bitmap>& baked,
const std::string& stem) {
BakedAsset result;
if (!baked || baked->width() <= 0 || baked->height() <= 0) {
return result;
}
// Guard against runaway text/shape bakes. A single enormous bitmap (e.g. a text
// member used as a data buffer with a 200x285318 box) can exhaust process memory
// and make the export unusable. Members that exceed this cap are skipped during
// pre-bake; if they are genuinely rendered on a score sprite they will be baked
// per-frame using the sprite's actual dimensions in the main export loop.
constexpr std::size_t kMaxBakedPixels = 16 * 1024 * 1024;
const std::size_t pixelCount = static_cast<std::size_t>(baked->width()) * static_cast<std::size_t>(baked->height());
if (pixelCount > kMaxBakedPixels) {
std::cerr << "export_ts: warning: skipping oversized baked bitmap for " << stem
<< " (" << baked->width() << "x" << baked->height() << " = " << pixelCount
<< " pixels > " << kMaxBakedPixels << ")\n";
return result;
}
const std::uint64_t hash = fnv1a64(baked->pixels());
std::ostringstream keyStream;
keyStream << "b" << std::hex << hash << std::dec
<< "_w" << baked->width() << "_h" << baked->height();
const std::string key = keyStream.str();
result.assetRel = "assets/bitmaps/" + key + ".rgba";
result.width = baked->width();
result.height = baked->height();
if (writtenBitmaps.insert(key).second) {
writeRgba(outDir / result.assetRel, baked->pixels());
}
return result;
}
} // namespace
// Pre-bake all renderable cast members so Lingo member swaps have assets and
// filmloops can be animated in the browser. Mutates `memberNameToBitmapAsset`
// and writes new assets into `writtenBitmaps`.
void preBakeCastMemberAssets(
ls::player::Player& player,
ls::DirectorFile& directorFile,
const fs::path& outDir,
std::unordered_set<std::string>& writtenBitmaps,
std::unordered_map<std::string, std::string>& memberNameToBitmapAsset,
std::vector<CastMemberRecord>& castMembers,
const std::vector<std::pair<int, std::shared_ptr<ls::player::cast::CastLib>>>& extraCastlibs) {
auto& baker = player.spriteBaker();
const int initialTick = baker.tickCounter();
const auto bakeCastLibMembers = [&](int castLibNum,
const std::shared_ptr<ls::player::cast::CastLib>& castLib) {
if (!castLib) {
return;
}
for (const auto& [memberNumber, memberChunk] : castLib->memberChunks()) {
if (!memberChunk) {
continue;
}
const auto memberType = memberChunk->memberType();
const auto spriteType = spriteTypeForMemberType(memberType);
if (spriteType == rsp::SpriteType::Unknown) {
continue;
}
CastMemberRecord cm;
cm.id = memberNumber;
cm.castLib = castLibNum;
cm.name = memberChunk->name();
cm.type = std::string(::libreshockwave::cast::name(memberType));
cm.regX = memberChunk->regPointX();
cm.regY = memberChunk->regPointY();
if (auto member = castLib->getMember(memberNumber)) {
if (auto palette = member->paletteData()) {
cm.paletteColors = palette->colors();
}
}
// Text / Button / Shape members are not pre-baked. Their intrinsic
// dimensions can be enormous (text members used as data buffers with
// 200x285318 boxes), and the C++ frame pipeline bakes them per-frame
// with the sprite's actual dimensions when they appear on stage. We
// still record them in cast.json for name resolution and text content.
if (memberType == ::libreshockwave::cast::MemberType::Text
|| memberType == ::libreshockwave::cast::MemberType::Button
|| memberType == ::libreshockwave::cast::MemberType::Shape) {
castMembers.push_back(std::move(cm));
continue;
}
if (memberType == ::libreshockwave::cast::MemberType::FilmLoop) {
// Film-loops: bake each internal sub-frame so the runtime can
// cycle through them based on the current bake tick.
int frameCount = 1;
if (const auto score = directorFile.getScoreForMember(
std::const_pointer_cast<ls::chunks::CastMemberChunk>(memberChunk))) {
frameCount = score->frameData().header.frameCount;
}
if (frameCount <= 0) {
frameCount = 1;
}
std::vector<BakedAsset> loopAssets;
loopAssets.reserve(static_cast<std::size_t>(frameCount));
for (int tick = 0; tick < frameCount; ++tick) {
baker.setTickCounter(tick);
rsp::RenderSprite sprite(0, 0, 0, 0, 0, false, rsp::SpriteType::FilmLoop,
memberChunk, 0, 0, 0, 100);
auto baked = baker.bake(sprite).bakedBitmap();
auto asset = bakeAndWriteAsset(outDir, writtenBitmaps, baked,
"fl_" + std::to_string(castLibNum) + "_" + std::to_string(memberNumber));
loopAssets.push_back(asset);
}
cm.filmLoopFrames.reserve(loopAssets.size());
for (const auto& asset : loopAssets) {
cm.filmLoopFrames.push_back(asset.assetRel);
}
if (!loopAssets.empty() && !loopAssets[0].assetRel.empty()) {
cm.bakedBitmapAsset = loopAssets[0].assetRel;
cm.bakedWidth = loopAssets[0].width;
cm.bakedHeight = loopAssets[0].height;
}
const std::string memberName = cm.name;
const std::string firstAsset = cm.bakedBitmapAsset;
castMembers.push_back(std::move(cm));
if (!memberName.empty() && !firstAsset.empty()) {
memberNameToBitmapAsset.emplace(memberName, firstAsset);
}
continue;
}
// Bitmap / text / shape: a single baked asset is enough for the
// static member lookup (Lingo-driven ink/scale is applied at render).
rsp::RenderSprite sprite(0, 0, 0, 0, 0, false, spriteType,
memberChunk, 0, 0, 0, 100);
auto baked = baker.bake(sprite).bakedBitmap();
auto asset = bakeAndWriteAsset(outDir, writtenBitmaps, baked,
"cm_" + std::to_string(castLibNum) + "_" + std::to_string(memberNumber));
cm.bakedBitmapAsset = asset.assetRel;
cm.bakedWidth = asset.width;
cm.bakedHeight = asset.height;
const std::string memberName = cm.name;
const std::string bakedAsset = cm.bakedBitmapAsset;
castMembers.push_back(std::move(cm));
if (!memberName.empty() && !bakedAsset.empty()) {
memberNameToBitmapAsset.emplace(memberName, bakedAsset);
}
}
};
for (const auto& [castLibNum, castLib] : player.castLibManager().castLibs()) {
bakeCastLibMembers(castLibNum, castLib);
}
// player.play() may unload external cast member chunks from the active
// player while keeping them in our pre-play snapshot. Re-bake from the
// snapshot so locale-specific bitmaps (e.g. hh_entry_au.cct) are not
// lost before they can be written to assets/bitmaps/.
for (const auto& [castLibNum, castLib] : extraCastlibs) {
bakeCastLibMembers(castLibNum, castLib);
}
baker.setTickCounter(initialTick);
}
// One Lingo handler within an emitted script module. `event` is the Director system-event
// name when the handler is one of the recognized event handlers (enterFrame, mouseDown, ...),
// else null (a handler called only from other Lingo). Args are the resolved argument names.
struct ScriptHandlerRecord {
std::string name;
std::vector<std::string> args;
std::string event; // empty => not a system event handler
};
// A decompiled Lingo script emitted as a TS module under src/scripts/. The Lingo source is
// preserved verbatim (LingoDecompiler output) alongside a structured handler table; a TS Lingo
// execution model is the remaining Stage 7 tail, so the emitted stubs throw rather than execute.
struct ScriptRecord {
std::string name; // Director script name (cast member name)
std::string type; // Score | Behavior | MovieScript | Parent
std::string file; // src/scripts/<stem>.ts
int castLib = 0; // cast library number the script member lives in
int castMember = 0; // cast member number within that library
std::vector<ScriptHandlerRecord> handlers;
};
// Emit one LingoScriptChunk as a TS module under `outputDir / outputPrefix / <stem>.ts` and
// return a populated `ScriptRecord` describing it. The `outputPrefix` is "src/scripts/" for
// main-movie scripts and "src/castlib_scripts/" for scripts pulled out of directory-walk
// .cct files. `stemPrefix` is prepended to the stem before dedup (e.g. "hh_paalu_") so two
// castlibs that both contain a script named "init" don't collide. `namesFallbackFile` is
// the main-movie DirectorFile, used when the script's owning DirectorFile doesn't expose
// a ScriptNamesChunk (older castlibs). `scriptCastLib` is the same CastLib the script
// belongs to, used as a fallback for ScriptNamesChunk resolution.
ScriptRecord emitScriptModule(
std::shared_ptr<ls::chunks::ScriptChunk> script,
ls::DirectorFile& owningFile,
ls::DirectorFile& namesFallbackFile,
const std::shared_ptr<ls::player::cast::CastLib>& scriptCastLib,
int castLibNum,
int castMemberNum,
const fs::path& outputDir,
const std::string& outputPrefix,
const std::string& stemPrefix,
std::unordered_set<std::string>& writtenFiles,
std::size_t scriptIndex) {
if (!script) {
throw std::runtime_error("emitScriptModule called with null script");
}
// Resolve the script name from the ownership map (passed in via rawName param),
// then from the script's own name field, then from the fallback DirectorFile.
std::string rawName = script->scriptName();
if (rawName.empty()) {
rawName = namesFallbackFile.getScriptName(script);
}
std::string stem = sanitizeAssetName(rawName);
if (stem.empty()) {
stem = "script_" + std::to_string(scriptIndex);
}
const std::string prefixedStem = stemPrefix + stem;
std::size_t totalInstructions = 0;
std::size_t maxHandlerInstructions = 0;
std::size_t literalStringBytes = 0;
for (const auto& h : script->handlers()) {
totalInstructions += h.instructions.size();
maxHandlerInstructions = std::max(maxHandlerInstructions, h.instructions.size());
}
for (const auto& lit : script->literals()) {
if (std::holds_alternative<std::string>(lit.value)) {
literalStringBytes += std::get<std::string>(lit.value).size();
}
}
std::cerr << "export_ts: script name=" << rawName
<< " stem=" << prefixedStem
<< " castLib=" << castLibNum
<< " castMember=" << castMemberNum
<< " handlers=" << script->handlers().size()
<< " instructions=" << totalInstructions
<< " maxHandler=" << maxHandlerInstructions
<< " literalBytes=" << literalStringBytes
<< "\n";
// Stem dedup: pick the first non-colliding form. Use the prefixed stem for the
// initial insert so two castlibs with the same script name don't collide.
std::string base = prefixedStem;
int suffix = 1;
std::string dedupStem = base;
while (!writtenFiles.insert(dedupStem + ".ts").second) {
dedupStem = base + "_" + std::to_string(++suffix);
}
// Resolve the script names chunk from the script's owning DirectorFile context first.
// External cast libraries are loaded as separate DirectorFile objects, so their scripts
// report script->file() as that external file; using the main movie DirectorFile here
// resolves the wrong LNAM section and leaves handler names as <unknown:N>.
std::shared_ptr<ls::chunks::ScriptNamesChunk> names;
if (script->file()) {
names = const_cast<ls::DirectorFile*>(script->file())->getScriptNamesForScript(script);
}
if (!names && scriptCastLib) {
names = scriptCastLib->scriptNames();
}
if (!names) {
names = owningFile.getScriptNamesForScript(script);
}
if (!names) {
names = namesFallbackFile.scriptNames();
}
const ls::chunks::ScriptNamesChunk* namesPtr = names.get();
// Decompile the whole script (all handlers) to a single readable Lingo listing.
std::string lingoSource;
try {
ls::lingo::decompiler::LingoDecompiler decompiler;
lingoSource = decompiler.decompile(*script, namesPtr);
} catch (const std::exception& dex) {
lingoSource = "-- decompile failed: " + std::string(dex.what()) + "\n";
}
// Build the handler table from the parsed handler metadata.
ScriptRecord rec;
rec.name = rawName.empty() ? dedupStem : rawName;
rec.type = scriptTypeToString(script->resolvedScriptType());
rec.file = outputPrefix + dedupStem + ".ts";
rec.castLib = castLibNum;
rec.castMember = castMemberNum;
for (const auto& handler : script->handlers()) {
ScriptHandlerRecord hr;
hr.name = script->resolveName(handler.nameId, namesPtr);
if (hr.name.empty()) {
hr.name = "handler_" + std::to_string(handler.nameId);
}
hr.event = isLingoSystemEvent(hr.name) ? hr.name : "";
for (std::size_t ai = 0; ai < handler.argNameIds.size(); ++ai) {
std::string argName = script->resolveName(handler.argNameIds[ai], namesPtr);
if (argName.empty()) {
argName = "arg" + std::to_string(ai);
}
hr.args.push_back(std::move(argName));
}
rec.handlers.push_back(std::move(hr));
}
// Emit the TS module.
std::ostringstream ts;
ts << "// @ts-nocheck\n";
ts << "// Auto-generated from the decompiled Lingo script \""
<< jsonEscape(rec.name) << "\" (type: " << rec.type << ").\n";
ts << "//\n";
ts << "// Stage 7 emission. The decompiled Lingo source is preserved verbatim in "
<< "`lingoSource` (produced by the LibreShockwave LingoDecompiler — the same code\n";
ts << "// the C++ player uses to disassemble scripts). The `handlerStubs` table delegates "
<< "to transpiled TypeScript\n";
ts << "// functions below, which execute live in the browser via the LingoRuntimeHost. "
<< "Unhandled AST nodes fall back to\n";
ts << "// throwing `LingoNotImplemented`. Re-export to regenerate; do not hand-edit.\n\n";
ts << "import {\n";
ts << " LingoNotImplemented,\n";
ts << " type LingoMe, type LingoValue,\n";
ts << "} from \"../runtime/lingo-runtime.js\";\n\n";
ts << "export const lsScriptName = \"" << jsonEscape(rec.name) << "\";\n";
ts << "export const lsScriptType = \"" << jsonEscape(rec.type) << "\";\n";
ts << "export const lsCastLib = " << rec.castLib << ";\n";
ts << "export const lsCastMember = " << rec.castMember << ";\n";