Skip to content

Commit d75ade4

Browse files
author
DavidQ
committed
PR_08_04_TOOL_STATE_BINDING
1 parent 6aa68ce commit d75ade4

10 files changed

Lines changed: 194 additions & 24 deletions

File tree

docs/dev/CODEX_COMMANDS.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
11
MODEL: GPT-5.4
22
REASONING: medium
3-
4-
COMMAND:
5-
Implement PR_08_03_TOOL_LIVE_PREVIEW_SYNC.
3+
COMMAND: Implement PR_08_04_TOOL_STATE_BINDING

docs/dev/COMMIT_COMMENT.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
PR_08_03_TOOL_LIVE_PREVIEW_SYNC
1+
PR_08_04_TOOL_STATE_BINDING
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
PR: PR_08_04_TOOL_STATE_BINDING
2+
3+
Binding Contract
4+
- Channel: toolboxaid.livePreviewSync.v1
5+
- Event types:
6+
- tool-live-preview-sync
7+
- runtime-state-binding
8+
- Required payload fields:
9+
- toolId (non-empty string)
10+
- one of: runtimeState, tileMapDocument, parallaxDocument
11+
- runtimeState contract:
12+
- heroX, heroY, cameraX, cameraY must be finite numbers
13+
14+
Flow
15+
- Tool -> runtime: tile and parallax documents continue to publish via tool-live-preview-sync.
16+
- Runtime -> tools: sample 1208 publishes runtime-state-binding snapshots.
17+
- Tool consumers subscribe and apply runtime-state payloads for bound status readout.
18+
19+
Safety / Compatibility
20+
- Existing live preview payloads remain accepted.
21+
- Unknown or malformed payloads are rejected by contract validation.
22+
- No engine API changes; binding is tool/sample channel-level only.
Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
PR: PR_08_03_TOOL_LIVE_PREVIEW_SYNC
1+
PR: PR_08_04_TOOL_STATE_BINDING
22

33
Validation Commands
44
- node --check tools/shared/livePreviewSyncChannel.js
@@ -8,18 +8,10 @@ Validation Commands
88
- node --check samples/phase-12/1208/ToolFormattedTilesParallaxScene.js
99
- node tests/runtime/LaunchSmokeAllEntries.test.mjs
1010

11-
Results
12-
- Syntax checks: PASS
13-
- Runtime smoke: PASS
11+
Expected Runtime Checks
12+
- tool-live-preview-sync payloads still apply in sample 1208.
13+
- runtime-state-binding payloads are emitted from sample 1208.
14+
- tile/parallax tools accept valid runtime-state-binding payloads only.
1415

15-
Scope Verification
16-
- Changed implementation files:
17-
- tools/shared/livePreviewSyncChannel.js
18-
- tools/Tilemap Studio/main.js
19-
- tools/Parallax Scene Studio/main.js
20-
- samples/phase-12/1208/main.js
21-
- samples/phase-12/1208/ToolFormattedTilesParallaxScene.js
22-
- Deliverable docs:
23-
- docs/dev/reports/live_sync_model.txt
24-
- docs/dev/reports/validation_checklist.txt
25-
- No engine core API modifications performed.
16+
Result
17+
- PASS
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# PR_08_04_TOOL_STATE_BINDING
2+
3+
## Purpose
4+
Formalize state binding between tools and runtime.
5+
6+
## Tasks
7+
- define binding contract
8+
- sync tool state to runtime
9+
- validate two-way updates
10+
11+
## Output
12+
<project folder>/tmp/PR_08_04_TOOL_STATE_BINDING.zip

samples/phase-12/1208/ToolFormattedTilesParallaxScene.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export default class ToolFormattedTilesParallaxScene extends Scene {
4141
this.contentError = null;
4242
this.liveSyncVersion = 0;
4343
this.liveSyncPending = Promise.resolve();
44+
this.runtimeBindingPublisher = null;
45+
this.lastRuntimeBindingTimestamp = 0;
4446
this.tilesetAssetPath = '';
4547
this.tilesetImage = null;
4648
this.tileFrameById = {};
@@ -255,6 +257,7 @@ export default class ToolFormattedTilesParallaxScene extends Scene {
255257
this.camera.x = this.hero.x + this.hero.width * 0.5 - this.camera.viewportWidth * 0.5;
256258
this.camera.y = this.fixedCameraY;
257259
this.camera.clampToWorld();
260+
this.publishRuntimeBindingState(engine);
258261
}
259262

260263
moveHeroHorizontally(distance) {
@@ -452,6 +455,30 @@ export default class ToolFormattedTilesParallaxScene extends Scene {
452455
this.contentStatus = 'Live preview sync failed.';
453456
}
454457
}
458+
459+
setRuntimeBindingPublisher(publisher) {
460+
this.runtimeBindingPublisher = typeof publisher === 'function' ? publisher : null;
461+
}
462+
463+
publishRuntimeBindingState(engine) {
464+
if (!this.runtimeBindingPublisher) {
465+
return;
466+
}
467+
const now = (engine && Number.isFinite(Number(engine.time?.nowMs))) ? Number(engine.time.nowMs) : Date.now();
468+
if ((now - this.lastRuntimeBindingTimestamp) < 125) {
469+
return;
470+
}
471+
this.lastRuntimeBindingTimestamp = now;
472+
this.runtimeBindingPublisher({
473+
toolId: 'sample-1208-runtime',
474+
runtimeState: {
475+
heroX: Number(this.hero.x) || 0,
476+
heroY: Number(this.hero.y) || 0,
477+
cameraX: Number(this.camera?.x) || 0,
478+
cameraY: Number(this.camera?.y) || 0,
479+
},
480+
});
481+
}
455482
}
456483

457484
function loadImageFromRelativePath(relativePath, baseUrl) {

samples/phase-12/1208/main.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,8 @@ const livePreviewSync = createLivePreviewSyncBridge({ sourceId: 'sample-1208-liv
2929
livePreviewSync.subscribe((payload) => {
3030
scene.applyLivePreviewUpdate(payload);
3131
});
32+
scene.setRuntimeBindingPublisher((payload) => {
33+
livePreviewSync.publish(payload, 'runtime-state-binding');
34+
});
3235
engine.setScene(scene);
3336
engine.start();

tools/Parallax Scene Studio/main.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import { buildProjectPackage, summarizeProjectPackaging } from "../shared/projec
3131
import { buildEditorExperienceLayer, summarizeEditorExperienceLayer } from "../shared/editorExperienceLayer.js";
3232
import { buildDebugVisualizationLayer, summarizeDebugVisualizationLayer } from "../shared/debugVisualizationLayer.js";
3333
import { registerToolBootContract } from "../shared/toolBootContract.js";
34-
import { createLivePreviewSyncBridge } from "../shared/livePreviewSyncChannel.js";
34+
import { createLivePreviewSyncBridge, validateStateBindingPayload } from "../shared/livePreviewSyncChannel.js";
3535

3636
const SAMPLE_DIRECTORY_PATH = "./samples/";
3737
const SAMPLE_MANIFEST_PATH = "./samples/sample-manifest.json";
@@ -394,6 +394,8 @@ class ParallaxEditorApp {
394394
this.livePreviewSync = createLivePreviewSyncBridge({ sourceId: "parallax-editor" });
395395
this.livePreviewSyncFrame = 0;
396396
this.pendingLivePreviewReason = "init";
397+
this.boundRuntimeState = null;
398+
this.lastRuntimeBindingStatusAt = 0;
397399
}
398400

399401
invalidateImageCache() {
@@ -406,6 +408,7 @@ class ParallaxEditorApp {
406408
this.attachEvents();
407409
this.syncInputsFromDocument();
408410
this.renderAll();
411+
this.bindRuntimeStateSync();
409412
this.queueLivePreviewSync("init");
410413
this.loadSampleManifest();
411414
}
@@ -547,6 +550,29 @@ class ParallaxEditorApp {
547550
this.queueLivePreviewSync("document-update");
548551
}
549552

553+
bindRuntimeStateSync() {
554+
this.livePreviewSync.subscribe((payload, envelope) => {
555+
if (envelope?.eventType !== "runtime-state-binding") {
556+
return;
557+
}
558+
const validation = validateStateBindingPayload(payload);
559+
if (!validation.valid || !payload?.runtimeState) {
560+
return;
561+
}
562+
this.boundRuntimeState = payload.runtimeState;
563+
const now = Date.now();
564+
if ((now - this.lastRuntimeBindingStatusAt) < 500) {
565+
return;
566+
}
567+
this.lastRuntimeBindingStatusAt = now;
568+
const heroX = Number(this.boundRuntimeState.heroX).toFixed(1);
569+
const heroY = Number(this.boundRuntimeState.heroY).toFixed(1);
570+
const cameraX = Number(this.boundRuntimeState.cameraX).toFixed(1);
571+
const cameraY = Number(this.boundRuntimeState.cameraY).toFixed(1);
572+
this.updateStatus(`Runtime bound: hero(${heroX}, ${heroY}) camera(${cameraX}, ${cameraY}).`);
573+
});
574+
}
575+
550576
publishLivePreviewSync(reason = "update") {
551577
this.livePreviewSync.publish(
552578
{

tools/Tilemap Studio/main.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import { buildProjectPackage, summarizeProjectPackaging } from "../shared/projec
3131
import { buildEditorExperienceLayer, summarizeEditorExperienceLayer } from "../shared/editorExperienceLayer.js";
3232
import { buildDebugVisualizationLayer, summarizeDebugVisualizationLayer } from "../shared/debugVisualizationLayer.js";
3333
import { registerToolBootContract } from "../shared/toolBootContract.js";
34-
import { createLivePreviewSyncBridge } from "../shared/livePreviewSyncChannel.js";
34+
import { createLivePreviewSyncBridge, validateStateBindingPayload } from "../shared/livePreviewSyncChannel.js";
3535

3636
const DEFAULT_TILESET = [
3737
{ id: 0, name: "Empty", color: "transparent" },
@@ -585,13 +585,16 @@ class TileMapEditorApp {
585585
this.livePreviewSync = createLivePreviewSyncBridge({ sourceId: "tile-map-editor" });
586586
this.livePreviewSyncFrame = 0;
587587
this.pendingLivePreviewReason = "init";
588+
this.boundRuntimeState = null;
589+
this.lastRuntimeBindingStatusAt = 0;
588590
}
589591

590592
init(rootDocument) {
591593
this.captureRefs(rootDocument);
592594
this.attachEvents();
593595
this.syncInputsFromDocument();
594596
this.renderAll();
597+
this.bindRuntimeStateSync();
595598
this.queueLivePreviewSync("init");
596599
void this.reloadTilesetImageFromDocument({ quiet: true });
597600
void this.preloadIndividualTileImages({ quiet: true });
@@ -1564,6 +1567,29 @@ class TileMapEditorApp {
15641567
this.queueLivePreviewSync("document-update");
15651568
}
15661569

1570+
bindRuntimeStateSync() {
1571+
this.livePreviewSync.subscribe((payload, envelope) => {
1572+
if (envelope?.eventType !== "runtime-state-binding") {
1573+
return;
1574+
}
1575+
const validation = validateStateBindingPayload(payload);
1576+
if (!validation.valid || !payload?.runtimeState) {
1577+
return;
1578+
}
1579+
this.boundRuntimeState = payload.runtimeState;
1580+
const now = Date.now();
1581+
if ((now - this.lastRuntimeBindingStatusAt) < 500) {
1582+
return;
1583+
}
1584+
this.lastRuntimeBindingStatusAt = now;
1585+
const heroX = Number(this.boundRuntimeState.heroX).toFixed(1);
1586+
const heroY = Number(this.boundRuntimeState.heroY).toFixed(1);
1587+
const cameraX = Number(this.boundRuntimeState.cameraX).toFixed(1);
1588+
const cameraY = Number(this.boundRuntimeState.cameraY).toFixed(1);
1589+
this.updateStatus(`Runtime bound: hero(${heroX}, ${heroY}) camera(${cameraX}, ${cameraY}).`);
1590+
});
1591+
}
1592+
15671593
publishLivePreviewSync(reason = "update") {
15681594
this.livePreviewSync.publish(
15691595
{

tools/shared/livePreviewSyncChannel.js

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,81 @@
1-
const LIVE_PREVIEW_CHANNEL_NAME = "toolboxaid.livePreviewSync.v1";
1+
export const LIVE_PREVIEW_CHANNEL_NAME = "toolboxaid.livePreviewSync.v1";
22
const LIVE_PREVIEW_STORAGE_KEY = "__toolboxaid_live_preview_sync_v1__";
3+
const VALID_EVENT_TYPES = new Set([
4+
"update",
5+
"tool-live-preview-sync",
6+
"runtime-state-binding"
7+
]);
38

49
function sanitizeText(value) {
510
return typeof value === "string" ? value.trim() : "";
611
}
712

813
function toMessageEnvelope(sourceId, payload, eventType) {
14+
const normalizedEventType = sanitizeText(eventType) || "update";
915
return {
1016
channel: LIVE_PREVIEW_CHANNEL_NAME,
1117
sourceId: sanitizeText(sourceId) || "unknown-source",
12-
eventType: sanitizeText(eventType) || "update",
18+
eventType: VALID_EVENT_TYPES.has(normalizedEventType) ? normalizedEventType : "update",
1319
updatedAt: Date.now(),
1420
payload
1521
};
1622
}
1723

24+
function hasObjectField(payload, key) {
25+
return payload
26+
&& Object.prototype.hasOwnProperty.call(payload, key)
27+
&& payload[key]
28+
&& typeof payload[key] === "object";
29+
}
30+
31+
function hasRuntimeState(payload) {
32+
return hasObjectField(payload, "runtimeState");
33+
}
34+
35+
function hasToolStatePayload(payload) {
36+
return hasObjectField(payload, "tileMapDocument")
37+
|| hasObjectField(payload, "parallaxDocument");
38+
}
39+
40+
export function validateStateBindingPayload(payload) {
41+
if (!payload || typeof payload !== "object") {
42+
return { valid: false, reason: "payload must be an object." };
43+
}
44+
45+
const toolId = sanitizeText(payload.toolId || "");
46+
if (!toolId) {
47+
return { valid: false, reason: "payload.toolId is required." };
48+
}
49+
50+
if (!hasRuntimeState(payload) && !hasToolStatePayload(payload)) {
51+
return {
52+
valid: false,
53+
reason: "payload must include runtimeState, tileMapDocument, or parallaxDocument."
54+
};
55+
}
56+
57+
if (hasRuntimeState(payload)) {
58+
const runtimeState = payload.runtimeState;
59+
const heroX = Number(runtimeState.heroX);
60+
const heroY = Number(runtimeState.heroY);
61+
const cameraX = Number(runtimeState.cameraX);
62+
const cameraY = Number(runtimeState.cameraY);
63+
if (
64+
!Number.isFinite(heroX)
65+
|| !Number.isFinite(heroY)
66+
|| !Number.isFinite(cameraX)
67+
|| !Number.isFinite(cameraY)
68+
) {
69+
return {
70+
valid: false,
71+
reason: "runtimeState requires finite hero/camera coordinates."
72+
};
73+
}
74+
}
75+
76+
return { valid: true };
77+
}
78+
1879
export function createLivePreviewSyncBridge(options = {}) {
1980
const sourceId = sanitizeText(options.sourceId) || "unknown-source";
2081
const onMessage = typeof options.onMessage === "function" ? options.onMessage : null;
@@ -60,6 +121,10 @@ export function createLivePreviewSyncBridge(options = {}) {
60121
if (disposed || !payload || typeof payload !== "object") {
61122
return { status: "ignored" };
62123
}
124+
const validation = validateStateBindingPayload(payload);
125+
if (!validation.valid) {
126+
return { status: "rejected", reason: validation.reason };
127+
}
63128
const signature = JSON.stringify(payload);
64129
if (signature === lastPayloadSignature) {
65130
return { status: "deduped" };
@@ -104,4 +169,3 @@ export function createLivePreviewSyncBridge(options = {}) {
104169
dispose
105170
};
106171
}
107-

0 commit comments

Comments
 (0)