azul-sync 1.3.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 (133) hide show
  1. package/.gitattributes +1 -0
  2. package/.github/ISSUE_TEMPLATE/bug_report.md +31 -0
  3. package/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  4. package/README.md +142 -0
  5. package/dist/build.d.ts +19 -0
  6. package/dist/build.d.ts.map +1 -0
  7. package/dist/build.js +92 -0
  8. package/dist/build.js.map +1 -0
  9. package/dist/cli.d.ts +3 -0
  10. package/dist/cli.d.ts.map +1 -0
  11. package/dist/cli.js +397 -0
  12. package/dist/cli.js.map +1 -0
  13. package/dist/config.d.ts +26 -0
  14. package/dist/config.d.ts.map +1 -0
  15. package/dist/config.js +105 -0
  16. package/dist/config.js.map +1 -0
  17. package/dist/fs/fileWriter.d.ts +100 -0
  18. package/dist/fs/fileWriter.d.ts.map +1 -0
  19. package/dist/fs/fileWriter.js +342 -0
  20. package/dist/fs/fileWriter.js.map +1 -0
  21. package/dist/fs/treeManager.d.ts +84 -0
  22. package/dist/fs/treeManager.d.ts.map +1 -0
  23. package/dist/fs/treeManager.js +365 -0
  24. package/dist/fs/treeManager.js.map +1 -0
  25. package/dist/fs/watcher.d.ts +39 -0
  26. package/dist/fs/watcher.d.ts.map +1 -0
  27. package/dist/fs/watcher.js +120 -0
  28. package/dist/fs/watcher.js.map +1 -0
  29. package/dist/index.d.ts +61 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +349 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/ipc/httpPolling.d.ts +56 -0
  34. package/dist/ipc/httpPolling.d.ts.map +1 -0
  35. package/dist/ipc/httpPolling.js +171 -0
  36. package/dist/ipc/httpPolling.js.map +1 -0
  37. package/dist/ipc/messages.d.ts +112 -0
  38. package/dist/ipc/messages.d.ts.map +1 -0
  39. package/dist/ipc/messages.js +5 -0
  40. package/dist/ipc/messages.js.map +1 -0
  41. package/dist/ipc/server.d.ts +50 -0
  42. package/dist/ipc/server.d.ts.map +1 -0
  43. package/dist/ipc/server.js +168 -0
  44. package/dist/ipc/server.js.map +1 -0
  45. package/dist/pack.d.ts +19 -0
  46. package/dist/pack.d.ts.map +1 -0
  47. package/dist/pack.js +225 -0
  48. package/dist/pack.js.map +1 -0
  49. package/dist/push.d.ts +43 -0
  50. package/dist/push.d.ts.map +1 -0
  51. package/dist/push.js +532 -0
  52. package/dist/push.js.map +1 -0
  53. package/dist/rojo.d.ts +9 -0
  54. package/dist/rojo.d.ts.map +1 -0
  55. package/dist/rojo.js +114 -0
  56. package/dist/rojo.js.map +1 -0
  57. package/dist/snapshot/rojo.d.ts +39 -0
  58. package/dist/snapshot/rojo.d.ts.map +1 -0
  59. package/dist/snapshot/rojo.js +364 -0
  60. package/dist/snapshot/rojo.js.map +1 -0
  61. package/dist/snapshot.d.ts +23 -0
  62. package/dist/snapshot.d.ts.map +1 -0
  63. package/dist/snapshot.js +132 -0
  64. package/dist/snapshot.js.map +1 -0
  65. package/dist/sourcemap/generator.d.ts +78 -0
  66. package/dist/sourcemap/generator.d.ts.map +1 -0
  67. package/dist/sourcemap/generator.js +351 -0
  68. package/dist/sourcemap/generator.js.map +1 -0
  69. package/dist/sourcemap/propertyLoader.d.ts +19 -0
  70. package/dist/sourcemap/propertyLoader.d.ts.map +1 -0
  71. package/dist/sourcemap/propertyLoader.js +131 -0
  72. package/dist/sourcemap/propertyLoader.js.map +1 -0
  73. package/dist/util/id.d.ts +9 -0
  74. package/dist/util/id.d.ts.map +1 -0
  75. package/dist/util/id.js +14 -0
  76. package/dist/util/id.js.map +1 -0
  77. package/dist/util/log.d.ts +13 -0
  78. package/dist/util/log.d.ts.map +1 -0
  79. package/dist/util/log.js +51 -0
  80. package/dist/util/log.js.map +1 -0
  81. package/docs/assets/azul-logo.pdn +0 -0
  82. package/docs/assets/logo-200px.png +0 -0
  83. package/docs/assets/logo.png +0 -0
  84. package/docs/assets/plugin/toolbox.png +0 -0
  85. package/docs/assets/synced.png +0 -0
  86. package/package.json +41 -0
  87. package/plugin/README.md +54 -0
  88. package/plugin/sourcemap.json +264 -0
  89. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/Actor/AzulSync.server.luau +905 -0
  90. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/AzulService.luau +1010 -0
  91. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/Config.luau +29 -0
  92. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/Enums.luau +11 -0
  93. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/CollapsibleTitledSection.luau +214 -0
  94. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/ColorPicker.luau +360 -0
  95. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/CustomTextButton.luau +170 -0
  96. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/DropdownMenu.luau +363 -0
  97. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/HorizontalLine.luau +43 -0
  98. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/ImageButtonWithText.luau +181 -0
  99. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledCheckbox.luau +295 -0
  100. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledColorInputPicker.luau +294 -0
  101. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledMultiChoice.luau +163 -0
  102. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledNumberInput.luau +312 -0
  103. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledRadioButton.luau +55 -0
  104. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledSlider.luau +151 -0
  105. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledTextInput.luau +222 -0
  106. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledToggleButton.luau +73 -0
  107. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/StatefulImageButton.luau +125 -0
  108. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/VerticalScrollingFrame.luau +100 -0
  109. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/VerticalSpacer.luau +35 -0
  110. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/VerticallyScalingListFrame.luau +107 -0
  111. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/GuiUtilities.luau +429 -0
  112. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/RbxGui.luau +4363 -0
  113. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/UI.luau +425 -0
  114. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/WebSocketClient.luau +161 -0
  115. package/src/build.ts +120 -0
  116. package/src/cli.ts +496 -0
  117. package/src/config.ts +170 -0
  118. package/src/fs/fileWriter.ts +414 -0
  119. package/src/fs/treeManager.ts +458 -0
  120. package/src/fs/watcher.ts +142 -0
  121. package/src/index.ts +450 -0
  122. package/src/ipc/httpPolling.ts +214 -0
  123. package/src/ipc/messages.ts +159 -0
  124. package/src/ipc/server.ts +196 -0
  125. package/src/pack.ts +309 -0
  126. package/src/push.ts +726 -0
  127. package/src/snapshot/rojo.ts +467 -0
  128. package/src/snapshot.ts +161 -0
  129. package/src/sourcemap/generator.ts +504 -0
  130. package/src/sourcemap/propertyLoader.ts +195 -0
  131. package/src/util/id.ts +15 -0
  132. package/src/util/log.ts +94 -0
  133. package/tsconfig.json +24 -0
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Types for communication protocol between Studio and Daemon
3
+ */
4
+
5
+ export type InstanceClassName =
6
+ | "Script"
7
+ | "LocalScript"
8
+ | "ModuleScript"
9
+ | "Folder"
10
+ | "Model"
11
+ | "Part"
12
+ | "MeshPart"
13
+ | "Tool"
14
+ | "Configuration"
15
+ | string; // Allow any Roblox class
16
+
17
+ /**
18
+ * Represents a single instance in the DataModel
19
+ */
20
+ export interface InstanceData {
21
+ guid: string;
22
+ className: InstanceClassName;
23
+ name: string;
24
+ path: string[]; // ["ReplicatedStorage", "Modules", "Foo"]
25
+ parentGuid?: string | null; // parent instance GUID
26
+ source?: string; // Only present for Script/LocalScript/ModuleScript
27
+ properties?: Record<string, unknown>;
28
+ attributes?: Record<string, unknown>;
29
+ }
30
+
31
+ export interface SnapshotRequestOptions {
32
+ includeProperties?: boolean;
33
+ scriptsAndDescendantsOnly?: boolean;
34
+ }
35
+
36
+ /**
37
+ * Messages from Studio → Daemon
38
+ */
39
+ export type StudioPayloadMessage =
40
+ | FullSnapshotMessage
41
+ | InstanceUpdatedMessage
42
+ | ScriptChangedMessage
43
+ | DeletedMessage
44
+ | PingMessage
45
+ | ClientDisconnect
46
+ | PushConfigMessage;
47
+
48
+ export interface BatchMessage {
49
+ type: "batch";
50
+ messages: StudioPayloadMessage[];
51
+ }
52
+
53
+ export type StudioMessage = StudioPayloadMessage | BatchMessage;
54
+
55
+ export interface FullSnapshotMessage {
56
+ type: "fullSnapshot";
57
+ data: InstanceData[];
58
+ }
59
+
60
+ export interface InstanceUpdatedMessage {
61
+ type: "instanceUpdated";
62
+ data: InstanceData;
63
+ }
64
+
65
+ export interface ScriptChangedMessage {
66
+ type: "scriptChanged";
67
+ data: {
68
+ guid: string;
69
+ path: string[];
70
+ className: InstanceClassName;
71
+ source: string;
72
+ };
73
+ }
74
+
75
+ export interface DeletedMessage {
76
+ type: "deleted";
77
+ data: {
78
+ guid: string;
79
+ };
80
+ }
81
+
82
+ export interface PingMessage {
83
+ type: "ping";
84
+ }
85
+
86
+ export interface ClientDisconnect {
87
+ type: "clientDisconnect";
88
+ }
89
+
90
+ export interface PushConfigMessage {
91
+ type: "pushConfig";
92
+ config: PushConfig;
93
+ }
94
+
95
+ /**
96
+ * Messages from Daemon → Studio
97
+ */
98
+ export type DaemonMessage =
99
+ | PatchScriptMessage
100
+ | RequestSnapshotMessage
101
+ | PongMessage
102
+ | ErrorMessage
103
+ | BuildSnapshotMessage
104
+ | RequestPushConfigMessage
105
+ | PushSnapshotMessage;
106
+
107
+ export interface PatchScriptMessage {
108
+ type: "patchScript";
109
+ guid: string;
110
+ source: string;
111
+ }
112
+
113
+ export interface RequestSnapshotMessage {
114
+ type: "requestSnapshot";
115
+ options?: SnapshotRequestOptions;
116
+ }
117
+
118
+ export interface PongMessage {
119
+ type: "pong";
120
+ }
121
+
122
+ export interface ErrorMessage {
123
+ type: "error";
124
+ message: string;
125
+ }
126
+
127
+ export interface BuildSnapshotMessage {
128
+ type: "buildSnapshot";
129
+ data: InstanceData[];
130
+ }
131
+
132
+ export interface RequestPushConfigMessage {
133
+ type: "requestPushConfig";
134
+ }
135
+
136
+ export interface PushSnapshotMessage {
137
+ type: "pushSnapshot";
138
+ mappings: PushSnapshotMapping[];
139
+ }
140
+
141
+ export interface PushSnapshotMapping {
142
+ destination: string[];
143
+ destructive?: boolean;
144
+ instances: InstanceData[];
145
+ }
146
+
147
+ export interface PushConfig {
148
+ mappings: PushConfigMapping[];
149
+ port?: number;
150
+ debugMode?: boolean;
151
+ deleteOrphansOnConnect?: boolean;
152
+ }
153
+
154
+ export interface PushConfigMapping {
155
+ source: string;
156
+ destination: string[];
157
+ destructive?: boolean;
158
+ rojoMode?: boolean;
159
+ }
@@ -0,0 +1,196 @@
1
+ import { WebSocketServer, WebSocket } from "ws";
2
+ import { log } from "../util/log.js";
3
+ import type { StudioMessage, DaemonMessage } from "./messages.js";
4
+ import type { SnapshotRequestOptions } from "./messages.js";
5
+ import type { Server as HttpServer } from "http";
6
+
7
+ export type MessageHandler = (message: StudioMessage) => void;
8
+
9
+ interface IPCServerOptions {
10
+ requestSnapshotOnConnect?: boolean;
11
+ }
12
+
13
+ export class IPCServer {
14
+ private wss: WebSocketServer;
15
+ private client: WebSocket | null = null;
16
+ private messageHandler: MessageHandler | null = null;
17
+ private connectionHandler: (() => void) | null = null;
18
+ private requestSnapshotOnConnect: boolean;
19
+
20
+ constructor(port?: number, server?: HttpServer, options?: IPCServerOptions) {
21
+ this.requestSnapshotOnConnect = options?.requestSnapshotOnConnect !== false;
22
+ if (server) {
23
+ // Use existing HTTP server
24
+ this.wss = new WebSocketServer({
25
+ server,
26
+ // perMessageDeflate: false, // Roblox WebSocket client does not negotiate RSV2/RSV3 extensions
27
+ maxPayload: 256 * 1024 * 1024, // 256 MB
28
+ });
29
+ } else {
30
+ // Create standalone WebSocket server
31
+ this.wss = new WebSocketServer({
32
+ port: port || 8080,
33
+ // perMessageDeflate: false, // avoid RSV2/RSV3 bits from compression
34
+ maxPayload: 256 * 1024 * 1024, // 256 MB
35
+ });
36
+ }
37
+ this.setupServer();
38
+ }
39
+
40
+ private setupServer(): void {
41
+ this.wss.on("connection", (ws) => {
42
+ log.info("Studio client connected");
43
+ log.info("Waiting for Studio messages...");
44
+
45
+ // Disconnect previous client if exists
46
+ if (this.client) {
47
+ log.warn("Disconnecting previous client");
48
+ this.client.close();
49
+ }
50
+
51
+ this.client = ws;
52
+
53
+ if (this.connectionHandler) {
54
+ this.connectionHandler();
55
+ }
56
+
57
+ ws.on("message", (data) => {
58
+ try {
59
+ const message: StudioMessage = JSON.parse(data.toString());
60
+ log.debug(`Received: ${message.type}`);
61
+
62
+ if (this.messageHandler) {
63
+ this.messageHandler(message);
64
+ }
65
+ } catch (error) {
66
+ log.error("Failed to parse message:", error);
67
+ this.sendError("Invalid JSON message");
68
+ }
69
+ });
70
+
71
+ ws.on("close", () => {
72
+ log.info("Studio client disconnected");
73
+ this.client = null;
74
+ });
75
+
76
+ ws.on("error", (error) => {
77
+ log.error("WebSocket error:", error);
78
+ });
79
+
80
+ // Set up ping/pong to keep connection alive
81
+ ws.on("pong", () => {
82
+ log.debug("Received pong from client");
83
+ });
84
+
85
+ // Send ping every 30 seconds
86
+ const pingInterval = setInterval(() => {
87
+ if (this.client === ws && ws.readyState === WebSocket.OPEN) {
88
+ ws.ping();
89
+ } else {
90
+ clearInterval(pingInterval);
91
+ }
92
+ }, 30000);
93
+
94
+ // Request initial snapshot after a brief delay
95
+ if (this.requestSnapshotOnConnect) {
96
+ setTimeout(() => {
97
+ if (this.client === ws) {
98
+ this.send({ type: "requestSnapshot" });
99
+ }
100
+ }, 100);
101
+ }
102
+ });
103
+
104
+ this.wss.on("listening", () => {
105
+ log.success("WebSocket server ready");
106
+ });
107
+
108
+ this.wss.on("error", (error) => {
109
+ log.error("WebSocket server error:", error);
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Register a handler for incoming Studio messages
115
+ */
116
+ public onMessage(handler: MessageHandler): void {
117
+ this.messageHandler = handler;
118
+ }
119
+
120
+ /**
121
+ * Register a handler that fires when a Studio client connects
122
+ */
123
+ public onConnection(handler: () => void): void {
124
+ this.connectionHandler = handler;
125
+ }
126
+
127
+ /**
128
+ * Send a message to the connected Studio client
129
+ */
130
+ public send(message: DaemonMessage): boolean {
131
+ if (!this.client || this.client.readyState !== WebSocket.OPEN) {
132
+ log.warn("Cannot send message: no connected client");
133
+ return false;
134
+ }
135
+
136
+ try {
137
+ this.client.send(JSON.stringify(message));
138
+ log.debug(`Sent: ${message.type}`);
139
+ return true;
140
+ } catch (error) {
141
+ log.error("Failed to send message:", error);
142
+ return false;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Send a patch to update a script's source in Studio
148
+ */
149
+ public patchScript(guid: string, source: string): boolean {
150
+ return this.send({
151
+ type: "patchScript",
152
+ guid,
153
+ source,
154
+ });
155
+ }
156
+
157
+ /**
158
+ * Send an error message to Studio
159
+ */
160
+ public sendError(message: string): boolean {
161
+ return this.send({
162
+ type: "error",
163
+ message,
164
+ });
165
+ }
166
+
167
+ /**
168
+ * Request a full snapshot from Studio
169
+ */
170
+ public requestSnapshot(options?: SnapshotRequestOptions): boolean {
171
+ return this.send({
172
+ type: "requestSnapshot",
173
+ options,
174
+ });
175
+ }
176
+
177
+ /**
178
+ * Check if a client is connected
179
+ */
180
+ public isConnected(): boolean {
181
+ return this.client !== null && this.client.readyState === WebSocket.OPEN;
182
+ }
183
+
184
+ /**
185
+ * Close the server
186
+ */
187
+ public close(): void {
188
+ if (this.client) {
189
+ this.client.close();
190
+ }
191
+ this.wss.close();
192
+ log.info("WebSocket server closed.");
193
+ log.info("Exiting...");
194
+ process.exit(0);
195
+ }
196
+ }
package/src/pack.ts ADDED
@@ -0,0 +1,309 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { IPCServer } from "./ipc/server.js";
4
+ import { config } from "./config.js";
5
+ import { log } from "./util/log.js";
6
+ import type {
7
+ InstanceData,
8
+ SnapshotRequestOptions,
9
+ StudioMessage,
10
+ } from "./ipc/messages.js";
11
+
12
+ interface PackOptions {
13
+ outputPath?: string;
14
+ scriptsAndDescendantsOnly?: boolean;
15
+ }
16
+
17
+ interface SourcemapNode {
18
+ name: string;
19
+ className: string;
20
+ guid?: string;
21
+ filePaths?: string[];
22
+ children?: SourcemapNode[];
23
+ properties?: Record<string, unknown>;
24
+ attributes?: Record<string, unknown>;
25
+ }
26
+
27
+ interface SourcemapRoot {
28
+ name: string;
29
+ className: string;
30
+ children: SourcemapNode[];
31
+ _azul?: {
32
+ packVersion?: number;
33
+ packedAt?: string;
34
+ mode?: "all" | "scripts-and-descendants";
35
+ };
36
+ }
37
+
38
+ export class PackCommand {
39
+ private ipc: IPCServer;
40
+ private outputPath: string;
41
+ private scriptsAndDescendantsOnly: boolean;
42
+
43
+ constructor(options: PackOptions = {}) {
44
+ this.outputPath = path.resolve(options.outputPath ?? config.sourcemapPath);
45
+ this.scriptsAndDescendantsOnly = Boolean(options.scriptsAndDescendantsOnly);
46
+ this.ipc = new IPCServer(config.port, undefined, {
47
+ requestSnapshotOnConnect: false,
48
+ });
49
+ }
50
+
51
+ public async run(): Promise<void> {
52
+ log.info(`Waiting for Studio to connect on port ${config.port}...`);
53
+ const snapshot = await this.requestSnapshot({
54
+ includeProperties: true,
55
+ scriptsAndDescendantsOnly: this.scriptsAndDescendantsOnly,
56
+ });
57
+
58
+ if (!snapshot) {
59
+ log.error("Failed to receive snapshot from Studio for packing.");
60
+ return;
61
+ }
62
+
63
+ const existing = this.readExistingSourcemap();
64
+ const regenerated = this.regenerateSourcemap(snapshot, existing);
65
+ const packedCount = this.packIntoSourcemap(snapshot, regenerated);
66
+
67
+ this.writeSourcemap(regenerated, this.outputPath);
68
+ log.success(`Packed ${packedCount} node(s) into ${this.outputPath}`);
69
+ }
70
+
71
+ private async requestSnapshot(
72
+ options: SnapshotRequestOptions,
73
+ ): Promise<InstanceData[] | null> {
74
+ return new Promise<InstanceData[] | null>((resolve) => {
75
+ let timeoutHandle: NodeJS.Timeout | null = null;
76
+ let resolved = false;
77
+
78
+ const finalize = (result: InstanceData[] | null): void => {
79
+ if (resolved) return;
80
+ resolved = true;
81
+
82
+ if (timeoutHandle) {
83
+ clearTimeout(timeoutHandle);
84
+ timeoutHandle = null;
85
+ }
86
+
87
+ setTimeout(() => {
88
+ this.ipc.close();
89
+ }, 200);
90
+
91
+ resolve(result);
92
+ };
93
+
94
+ this.ipc.onMessage((message: StudioMessage) => {
95
+ if (message.type !== "fullSnapshot") return;
96
+ finalize(message.data);
97
+ });
98
+
99
+ this.ipc.onConnection(() => {
100
+ log.info("Studio connected. Requesting snapshot...");
101
+ this.ipc.requestSnapshot(options);
102
+ });
103
+
104
+ timeoutHandle = setTimeout(() => {
105
+ log.error("Timed out waiting for Studio snapshot.");
106
+ finalize(null);
107
+ }, 30000);
108
+ });
109
+ }
110
+
111
+ private readExistingSourcemap(): SourcemapRoot | null {
112
+ if (!fs.existsSync(this.outputPath)) {
113
+ return null;
114
+ }
115
+
116
+ try {
117
+ const raw = fs.readFileSync(this.outputPath, "utf8");
118
+ return JSON.parse(raw) as SourcemapRoot;
119
+ } catch (error) {
120
+ log.warn(
121
+ `Failed to read existing sourcemap at ${this.outputPath}: ${error}`,
122
+ );
123
+ return null;
124
+ }
125
+ }
126
+
127
+ private regenerateSourcemap(
128
+ snapshot: InstanceData[],
129
+ existing: SourcemapRoot | null,
130
+ ): SourcemapRoot {
131
+ const root: SourcemapRoot = {
132
+ name: "Game",
133
+ className: "DataModel",
134
+ children: [],
135
+ };
136
+
137
+ const guidFilePaths = new Map<string, string[]>();
138
+ const pathClassFilePaths = new Map<string, string[][]>();
139
+ const pathClassCursor = new Map<string, number>();
140
+
141
+ const indexExisting = (
142
+ node: SourcemapNode,
143
+ currentPath: string[],
144
+ ): void => {
145
+ const nodePath = [...currentPath, node.name];
146
+ if (node.filePaths && node.filePaths.length > 0) {
147
+ if (node.guid) {
148
+ guidFilePaths.set(node.guid, node.filePaths);
149
+ }
150
+
151
+ const key = this.pathClassKey(nodePath, node.className);
152
+ const bucket = pathClassFilePaths.get(key) ?? [];
153
+ bucket.push(node.filePaths);
154
+ pathClassFilePaths.set(key, bucket);
155
+ }
156
+
157
+ for (const child of node.children ?? []) {
158
+ indexExisting(child, nodePath);
159
+ }
160
+ };
161
+
162
+ for (const child of existing?.children ?? []) {
163
+ indexExisting(child, []);
164
+ }
165
+
166
+ const byGuid = new Map<string, SourcemapNode>();
167
+ byGuid.set("root", root as unknown as SourcemapNode);
168
+
169
+ const sorted = [...snapshot].sort((a, b) => {
170
+ if (a.path.length !== b.path.length) {
171
+ return a.path.length - b.path.length;
172
+ }
173
+ return a.path.join("/").localeCompare(b.path.join("/"));
174
+ });
175
+
176
+ for (const item of sorted) {
177
+ const node: SourcemapNode = {
178
+ name: item.name,
179
+ className: item.className,
180
+ guid: item.guid,
181
+ };
182
+
183
+ const directFilePaths = guidFilePaths.get(item.guid);
184
+ if (directFilePaths && directFilePaths.length > 0) {
185
+ node.filePaths = directFilePaths;
186
+ } else {
187
+ const key = this.pathClassKey(item.path, item.className);
188
+ const bucket = pathClassFilePaths.get(key);
189
+ if (bucket && bucket.length > 0) {
190
+ const cursor = pathClassCursor.get(key) ?? 0;
191
+ const candidate = bucket[cursor];
192
+ if (candidate && candidate.length > 0) {
193
+ node.filePaths = candidate;
194
+ pathClassCursor.set(key, cursor + 1);
195
+ }
196
+ }
197
+ }
198
+
199
+ let parentNode = root as SourcemapNode;
200
+ if (item.parentGuid && item.parentGuid !== "root") {
201
+ parentNode = byGuid.get(item.parentGuid) ?? root;
202
+ }
203
+
204
+ if (!parentNode.children) {
205
+ parentNode.children = [];
206
+ }
207
+ parentNode.children.push(node);
208
+ byGuid.set(item.guid, node);
209
+ }
210
+
211
+ return root;
212
+ }
213
+
214
+ private packIntoSourcemap(
215
+ snapshot: InstanceData[],
216
+ sourcemap: SourcemapRoot,
217
+ ): number {
218
+ const byGuid = new Map<string, InstanceData>();
219
+ const byPathClass = new Map<string, InstanceData[]>();
220
+ for (const item of snapshot) {
221
+ byGuid.set(item.guid, item);
222
+ const key = this.pathClassKey(item.path, item.className);
223
+ const bucket = byPathClass.get(key) ?? [];
224
+ bucket.push(item);
225
+ byPathClass.set(key, bucket);
226
+ }
227
+
228
+ const usedGuids = new Set<string>();
229
+ let packed = 0;
230
+
231
+ const visit = (node: SourcemapNode, currentPath: string[]): void => {
232
+ const nodePath = [...currentPath, node.name];
233
+ let match: InstanceData | undefined;
234
+
235
+ if (node.guid) {
236
+ const direct = byGuid.get(node.guid);
237
+ if (direct) {
238
+ match = direct;
239
+ usedGuids.add(direct.guid);
240
+ }
241
+ }
242
+
243
+ if (!match) {
244
+ const key = this.pathClassKey(nodePath, node.className);
245
+ const bucket = byPathClass.get(key);
246
+ if (bucket && bucket.length > 0) {
247
+ match = bucket.find((candidate) => !usedGuids.has(candidate.guid));
248
+ if (match) {
249
+ usedGuids.add(match.guid);
250
+ }
251
+ }
252
+ }
253
+
254
+ if (match) {
255
+ if (match.properties && Object.keys(match.properties).length > 0) {
256
+ node.properties = match.properties;
257
+ } else if (!this.scriptsAndDescendantsOnly) {
258
+ delete node.properties;
259
+ }
260
+
261
+ if (match.attributes && Object.keys(match.attributes).length > 0) {
262
+ node.attributes = match.attributes;
263
+ } else if (!this.scriptsAndDescendantsOnly) {
264
+ delete node.attributes;
265
+ }
266
+
267
+ if (match.properties || match.attributes) {
268
+ packed += 1;
269
+ }
270
+ } else if (!this.scriptsAndDescendantsOnly) {
271
+ delete node.properties;
272
+ delete node.attributes;
273
+ }
274
+
275
+ for (const child of node.children ?? []) {
276
+ visit(child, nodePath);
277
+ }
278
+ };
279
+
280
+ for (const child of sourcemap.children ?? []) {
281
+ visit(child, []);
282
+ }
283
+
284
+ sourcemap._azul = {
285
+ packVersion: 1,
286
+ packedAt: new Date().toISOString(),
287
+ mode: this.scriptsAndDescendantsOnly ? "scripts-and-descendants" : "all",
288
+ };
289
+
290
+ return packed;
291
+ }
292
+
293
+ private writeSourcemap(sourcemap: SourcemapRoot, outputPath: string): void {
294
+ const dir = path.dirname(outputPath);
295
+ if (dir && dir !== "." && !fs.existsSync(dir)) {
296
+ fs.mkdirSync(dir, { recursive: true });
297
+ }
298
+
299
+ fs.writeFileSync(
300
+ outputPath,
301
+ `${JSON.stringify(sourcemap, null, 2)}\n`,
302
+ "utf8",
303
+ );
304
+ }
305
+
306
+ private pathClassKey(pathSegments: string[], className: string): string {
307
+ return `${pathSegments.join("\u0001")}::${className}`;
308
+ }
309
+ }