diff --git a/docs/ROOM-PIPELINE-RUNBOOK.md b/docs/ROOM-PIPELINE-RUNBOOK.md index ffc831d0..032667ae 100644 --- a/docs/ROOM-PIPELINE-RUNBOOK.md +++ b/docs/ROOM-PIPELINE-RUNBOOK.md @@ -475,6 +475,15 @@ room inside the player while the build's root strip read the clean in-memory sce byte-scans the scene file it builds and refuses on any `KitRoom_` string — if a build refuses, `git -C /Users/m1/worldos-unity checkout -- Assets/Scenes/M1CombatV1_canonical.unity`, reopen the scene, rebuild. ⚠ In a HEADED Editor that restore raises the modal *"The open scene(s) have been modified externally — Ignore / Reload"*, which blocks the main thread (the MCP bridge heartbeat freezes and every `execute_code` times out) until it is answered. Answer **Reload** (memory == the restored file) via the cua driver: `list_windows(pid)` → the untitled ~260×320 window → `get_window_state` → click the `Reload` button. The "Load Input and Layer Presets" utility window (an animation pack's nag, re-shown on every script reload) is harmless. +### 9c. Live rooms — "the room is the scene" (Day 2-3, 2026-09-03) +A live room is the kit build of a room's walked geometry, textured with painted tileables through the `WorldOS/PainterlyRoom` shader, baked to a prefab and instantiated by the player under `LiveRoom_`. The picture, the collision and the occlusion are the same meshes: the seg-registration gate on the live render reads 100 % by construction. +1. **Textures** (once per room class, ~16 CU per surface best-of-2): six seamless tileables under `Assets/painterly/textures//{floor,wall,pillar,tomb,rubble,wood}.png` (wrap Repeat). Kind → file is resolved from the GEOMETRY kind (`_propKindById`), placed names only as a fallback. +2. **Build + look**: Editor env `WORLDOS_ROOM_GEO / WORLDOS_KIT_ROOM_ID / WORLDOS_ROOM_TEXTURES` → `BuildRoomKit.BuildRoom()` → apply the room's knob set (materials are in-memory; the knob set is the record — `session-notes/.../day-2/record.knobs` for the crypt) → `Tools/WorldOS/Kit/Capture Kit Room` writes the contract frame + the flat `_score.png`. +3. **Gates before any bake**: `qa/registration_score.py check <_score.png> --ortho --boxes ` = 100 %; the blind two-anchor panel (`qa/panel_workflow.mjs`, control = the PoE2 frame, calibration ref = a shipped plate) within 0.5 of the ref. Hide any actor proxies before a score capture. +4. **Bake**: `WORLDOS_KIT_ROOM_ID=` + `Tools/WorldOS/Kit/Bake Live Room Prefab` → `Assets/Resources/LiveRooms/.prefab` (+ `.json` = fire globals **and** the camera grade). It duplicates the QA root (never mutates it), drops `Cast`, renames every `KitRoom_*` → `LiveRoom_*`, and writes EVERY in-memory material to `LiveRooms//mat_NN_.mat` — the kit builds its materials at runtime, so an unsaved one ships as a missing (magenta) material. +5. **Ship**: manifest entry `"liveRoom": ""` + `"liveRoomMode": "visible"|"occluder"` (keep `plate`/`boxes`/`cameraPin`). `visible` = strategy B, the room is the picture and re-applies the baked grade. `occluder` = strategy D, the PLATE is the picture and the same prefab is re-materialled to `WorldOS/OccluderDepth` with its lights off, its floor renderers off and the sidecar box proxies skipped (one occluder per wall, never two). Then build; `qa/packaged_pins.py`; `strings level0 | grep -c KitRoom_` = 0 (the build now byte-scans `resources.assets`/`sharedassets*` too, so a dirty prefab reds the build); sandbox `walk_test --exhaustive --visual 4` + `player_cert --live` + `/debug` (`liveRoom`/`liveRoomMode`/`liveRoomRenderers`/`grade`/`fps`). +Traps: Unity demotes ForcePixel lights beyond `pixelLightCount` to vertex lights — the shader takes explicit fire globals and has no vertex-light term, so the bake captures those globals and a `PainterlyRoomLights` component re-arms them in the player; `CaptureRoom` hides everything outside the kit root; `GameObject.Find` skips inactive objects; the Beautify grade is EDITOR-ONLY scene state — the bake copies its values onto the prefab and `BuildMacOSPlayer` strips the component from the build scene copy, so the plate rooms stay ungraded; and **`BuildPipeline.BuildPlayer` raises Unity's own save-modified-scenes modal whenever the active scene is dirty, which deadlocks a remotely-driven editor** — bake first, then build from a clean scene (the build now refuses up front rather than hanging). + ### 10. Player pickup — hot-load for ITERATION, local rebuild for SHIP **★ Hot-load (empirically confirmed 2026-07-16, dwing gates):** on macOS, `StreamingAssets` is diff --git a/extensions/renderers/unity/plates_manifest.json b/extensions/renderers/unity/plates_manifest.json index 4f460b90..95e0314f 100644 --- a/extensions/renderers/unity/plates_manifest.json +++ b/extensions/renderers/unity/plates_manifest.json @@ -10,6 +10,8 @@ "yaw": 45 }, "boxes": "boxes/crypt_kit_v1_boxes.json", + "liveRoom": "crypt", + "liveRoomMode": "occluder", "effects": [ { "type": "fire_medium", diff --git a/extensions/renderers/unity/scripts/BuildMacOSPlayer.cs b/extensions/renderers/unity/scripts/BuildMacOSPlayer.cs index a103919c..56af2164 100644 --- a/extensions/renderers/unity/scripts/BuildMacOSPlayer.cs +++ b/extensions/renderers/unity/scripts/BuildMacOSPlayer.cs @@ -187,6 +187,25 @@ public static void EnsurePackaged() [MenuItem("Tools/WorldOS/Build/macOS Player (Universal)")] public static void Build() { + // #1793 Day 3, MEASURED 2026-09-03: BuildPipeline.BuildPlayer raises Unity's OWN "Scene(s) Have Been + // Modified — Save / Don't Save / Cancel" prompt whenever the ACTIVE scene is dirty. It does that + // regardless of BuildPlayerOptions.scenes, so the temp-copy strip below does NOT avoid it. In this + // headed-but-remotely-driven editor a modal DEADLOCKS the session (BOX.md #1196): it blocks the main + // thread, which is where the MCP bridge pumps, so every subsequent call times out and the only exits + // are a human clicking Save (which writes the canonical scene — the exact thing the strip exists to + // prevent) or Don't Save (which silently discards live editor work). Refuse BEFORE the modal instead. + { + var act = EditorSceneManager.GetActiveScene(); + if (act.IsValid() && act.isDirty) + FailBuild("refusing to build: the active scene '" + + (string.IsNullOrEmpty(act.path) ? "Untitled" : act.path) + + "' has UNSAVED changes, and BuildPlayer would raise a modal save prompt that deadlocks a " + + "remotely-driven editor. Resolve it deliberately first: either save that scene yourself, " + + "or bake what you need out of it (Tools/WorldOS/Kit/Bake Live Room Prefab writes the live " + + "room to Assets/Resources, so the build no longer needs the dirty scene) and reopen the " + + "scene clean, then rebuild."); + } + // #1436: package the runtime actor bundle + registry into StreamingAssets FIRST so the built // player can runtime-spawn actors for any campaign (not just the baked scene's cast). EnsurePackaged(); @@ -267,6 +286,14 @@ public static void Build() finally { if (buildScenePath != SceneToBuild) AssetDatabase.DeleteAsset(buildScenePath); } var s = report.summary; + // #1793 Day 3 (rooms-are-the-scene): the scene byte-scan above only proves the SCENE is clean. A live + // room now ships as a PREFAB under Assets/Resources (build_room_kit.BakeLiveRoomPrefab), which lands in + // resources.assets/sharedassets*, NOT in the scene — so the exact contamination class the scan exists to + // stop (a `KitRoom_*` construction inside the player) could walk straight back in through Resources. + // Run the SAME token scan over the built data files, i.e. over what qa_sandbox/owner_install read later. + string[] builtKitNames = s.result == BuildResult.Succeeded + ? KitNamesInBuiltData(s.outputPath) : new string[0]; + string reportPath = Path.Combine(outDir, ReportFileName); File.WriteAllText(reportPath, "result=" + s.result + "\n" + @@ -281,7 +308,14 @@ public static void Build() "architecture=" + archResult + "\n" + "alwaysIncludedShaders=" + string.Join(",", includedShaders) + "\n" + "strippedQARoots=" + (strippedQARoots.Length == 0 ? "(none)" : string.Join(",", strippedQARoots)) + "\n" + - "scenesBuilt=" + string.Join(",", options.scenes) + "\n"); + "scenesBuilt=" + string.Join(",", options.scenes) + "\n" + + "builtDataKitNames=" + (builtKitNames.Length == 0 ? "(none)" : string.Join(",", builtKitNames)) + "\n"); + + if (builtKitNames.Length > 0) + FailBuild("refusing to ship: the BUILT player data contains QA kit names " + + string.Join(",", builtKitNames) + " (a KitRoom_* construction reached the player through the " + + "scene or through Assets/Resources). Re-bake the live room prefab so nothing in it is named " + + QARootPrefix + "* (Tools/WorldOS/Kit/Bake Live Room Prefab renames every descendant to LiveRoom_)."); Debug.Log("[BuildMacOSPlayer] DONE result=" + s.result + " errors=" + s.totalErrors + " warnings=" + s.totalWarnings + " size=" + s.totalSize + " time=" + s.totalTime @@ -319,6 +353,74 @@ static string[] KitNamesOnDisk(string scenePath) return new List(foreign).ToArray(); } + // #1793 Day 3: the same whole-token scan run over the BUILT player's serialized data — level0 (the scene), + // resources.assets (everything under Assets/Resources, i.e. the live-room prefab + its materials) and the + // sharedassets*.assets the scene/prefab pull in. Byte-level (these files are binary; a text decode would + // corrupt the surrounding bytes and could split a token). Empty array = clean. + static string[] KitNamesInBuiltData(string appPath) + { + var foreign = new SortedSet(); + string dataDir = Path.Combine(appPath, "Contents", "Resources", "Data"); + if (!Directory.Exists(dataDir)) + { + Debug.LogWarning("[BuildMacOSPlayer] built data dir not found (" + dataDir + ") — kit scan skipped"); + return new string[0]; + } + var helpers = new HashSet { BuildRoomKit.KitHelperFire, BuildRoomKit.KitHelperTombGlow, BuildRoomKit.KitHelperCoolKey }; + var targets = new List(); + foreach (var f in Directory.GetFiles(dataDir)) + { + string n = Path.GetFileName(f); + if (n.StartsWith("level", StringComparison.Ordinal) + || n.StartsWith("resources.assets", StringComparison.Ordinal) + || n.StartsWith("sharedassets", StringComparison.Ordinal)) targets.Add(f); + } + foreach (var f in targets) + foreach (var tok in KitTokensInBytes(File.ReadAllBytes(f))) + if (!helpers.Contains(tok)) foreign.Add(tok); + Debug.Log("[BuildMacOSPlayer] built-data kit scan: " + targets.Count + " file(s), " + + (foreign.Count == 0 ? "clean" : "FOREIGN " + string.Join(",", new List(foreign).ToArray()))); + return new List(foreign).ToArray(); + } + + // Every whole `KitRoom_` occurrence in a byte buffer (token chars = A-Za-z0-9_-), matching the + // regex KitNamesOnDisk applies to the scene text. + static List KitTokensInBytes(byte[] bytes) + { + var hits = new List(); + var pre = System.Text.Encoding.ASCII.GetBytes(QARootPrefix); + for (int i = 0; i + pre.Length <= bytes.Length; i++) + { + bool match = true; + for (int k = 0; k < pre.Length; k++) if (bytes[i + k] != pre[k]) { match = false; break; } + if (!match) continue; + var sb = new System.Text.StringBuilder(QARootPrefix); + int j = i + pre.Length; + while (j < bytes.Length) + { + char c = (char)bytes[j]; + if (!(char.IsLetterOrDigit(c) || c == '_' || c == '-')) break; + sb.Append(c); j++; + } + hits.Add(sb.ToString()); + i = j - 1; + } + return hits; + } + + // Remove every BeautifyEffect.Beautify from a scene (reflection: the asset may be absent in a given + // project, and this file must still compile). Returns how many were removed. + static int StripCameraGrade(Scene scene) + { + var t = PainterlyRoomLights.BeautifyType(); + if (t == null) return 0; + int n = 0; + foreach (var go in scene.GetRootGameObjects()) + foreach (var c in go.GetComponentsInChildren(t, true)) + { UnityEngine.Object.DestroyImmediate(c, true); n++; } + return n; + } + static string[] StripQAConstructions(ref string buildScenePath) { var active = EditorSceneManager.GetActiveScene(); @@ -368,6 +470,14 @@ static string[] StripQAConstructions(ref string buildScenePath) foreach (var go in tmp.GetRootGameObjects()) if (go != null && go.name.StartsWith(QARootPrefix, StringComparison.Ordinal)) { UnityEngine.Object.DestroyImmediate(go); destroyed++; } + // #1793 Day 3b: the room-authoring session adds a Beautify post component to Main Camera to grade + // the LOOK capture. It lives only in memory — but this copy is taken FROM memory, so it would ship + // and grade every painted plate, none of which was authored or panel-judged under it. The grade is + // a property of a live room now (baked on its prefab, applied by CombatSurfaceClient.ApplyRoom), + // so strip it from the shipped scene: grade-free by construction, not by a runtime disable alone. + int graded = StripCameraGrade(tmp); + if (graded > 0) Debug.LogWarning("[BuildMacOSPlayer] removed " + graded + " editor-only " + + PainterlyRoomLights.BeautifyTypeName.Split(',')[0] + " component(s) from the build scene copy"); bool saved = EditorSceneManager.SaveScene(tmp); EditorSceneManager.CloseScene(tmp, true); if (!saved || destroyed != qaRoots.Count) diff --git a/extensions/renderers/unity/scripts/CombatSurfaceClient.cs b/extensions/renderers/unity/scripts/CombatSurfaceClient.cs index c42705cf..1b1ebdbc 100644 --- a/extensions/renderers/unity/scripts/CombatSurfaceClient.cs +++ b/extensions/renderers/unity/scripts/CombatSurfaceClient.cs @@ -433,7 +433,22 @@ class PlateEntry { public string plate; public float[] planeSize; public float o // per-cell footprint proxies (byte-identical prior behavior). public string boxesPath; // #1585/#1647: optional painted-door hotspots (plate-pixel click targets -> door_cell). - public System.Collections.Generic.List doorHotspots; } + public System.Collections.Generic.List doorHotspots; + // ROOMS-ARE-THE-SCENE (#1793 Day 3): optional `liveRoom` — the id of an edit-time-baked + // LIVE room prefab (Resources/LiveRooms/.prefab, build_room_kit.BakeLiveRoomPrefab). + // Present => ApplyRoom instantiates that room's real geometry under a LiveRoom_ root + // and the painted backdrop is switched OFF (the room IS the scene, strategy B). The + // OPTIONAL `liveRoomKeepBackdrop` keeps the plate behind the live room (strategy D). + // Absent => byte-identical painted-plate behavior. The occluder path is UNCHANGED either + // way: `boxes` (the kit-derived sidecar) stays the source of the depth proxies. + // `liveRoomMode`: "visible" (default, strategy B — the live room IS the picture) or + // "occluder" (strategy D — the painted plate stays the picture and the same baked kit + // meshes are re-materialled to the depth-only occluder shader, so what masks an actor is + // the room's real geometry instead of sidecar boxes). The crypt ships "occluder" after + // the blind beauty panel missed twice on the live render (Day 3 doctrine outcome). + public string liveRoom; + public string liveRoomMode = "visible"; + public bool liveRoomKeepBackdrop; } // world-space occluder boxes of the ACTIVE plate ({center,size} rows, kind!=floor), null => legacy path. System.Collections.Generic.List _plateBoxes; string _plateBoxesLocId = "\0"; // the location _plateBoxes reflects (sentinel => unset); guards the stale-leak (#1575) @@ -592,6 +607,12 @@ void Start() // WALKABLE-SLICE-V1 (item 6): load the OPTIONAL plate registry (per-location backdrop swap). Absent // -> no swap, the scene's baked plate stands (byte-identical to pre-W5e). LoadPlateManifest(); + // #1793 Day 3b: the crypt's LOOK is authored in a live editor scene that carries a Beautify post + // component on Main Camera. That scene is dirty in memory when a player is built, so the build's + // scene copy CAN carry that component into the player — where it would grade every painted plate, + // none of which was authored or panel-judged under it. Start it OFF; the grade is switched on only + // by a VISIBLE live room, from the values baked onto that room's prefab (ApplyRoom). + PainterlyRoomLights.DisableGrade(Camera.main); // VFX-ANCHORS: load the OPTIONAL effects registry (type -> prefab asset path). Absent -> no effects // resolve -> nothing spawns (byte-identical). Paired with the per-plate `effects` array above. LoadEffectsRegistry(); @@ -918,6 +939,13 @@ void RebuildOccluders() // the boxes are never in _spawned, so the actor despawn path never touches them and vice-versa). if (_occRoot != null) { Destroy(_occRoot); _occRoot = null; } _occRends.Clear(); _occFoot.Clear(); _occBotY.Clear(); // #1736: the tables die with the proxies they describe + // ROOMS-ARE-THE-SCENE strategy D: this room's occluders are the LIVE kit meshes ApplyRoom just + // instantiated and re-materialled to WorldOS/OccluderDepth. Building the sidecar boxes as well would + // double-occlude (two depth stamps for one wall, and the box is the coarser of the two), so the proxy + // path early-outs entirely. Chosen over clearing _plateBoxes because that would fall THROUGH to the + // legacy per-cell footprint proxies (_occRaw is non-empty for the crypt) — still a double occluder. + if (_liveRoomOccluding) + { Debug.Log("[CSC] occluders: live room meshes (" + _dbgLiveRoomRenderers + " renderers, loc=" + _locId + ") — proxy boxes skipped"); return; } // UNIFY-THE-FRAMES: the plate-box sidecar takes priority BEFORE the legacy empty-set early-out — // a room can have zero legacy occluder props yet a full box sidecar (walls always ship in it); // gating the sidecar behind _occRaw would silently drop every wall volume (codex review, #1575). @@ -2632,6 +2660,12 @@ AnimationClip DonorWalk() void Update() { + // #1793 Day 3: 1-second moving average of 1/unscaledDeltaTime, published for /debug. Sampled at the + // TOP of Update, outside every gate below, so the number is the player's real frame rate whether or + // not the QA channel is armed or a click is in flight. Frames/elapsed (not a per-frame mean) so a + // single hitch cannot bias it the way averaging instantaneous 1/dt does. + _fpsFrames++; _fpsElapsed += Time.unscaledDeltaTime; + if (_fpsElapsed >= 1f) { _dbgFps = _fpsFrames / _fpsElapsed; _fpsFrames = 0; _fpsElapsed = 0f; } // UNIFY-THE-FRAMES truth overlay (playtest-#9 instrument): G toggles engine-truth rendering over // the plate — every cell's floor diamond colored by its live state (walkable/impassable/door) + // wireframes of the active occluder volumes. The permanent "is paint lying?" check: no alignment @@ -2686,7 +2720,10 @@ void Update() _dbgCamValid = true; // #1582: occluder count (0 == the latent zero-occluder path), plate-room match, and // the party actor's own viewport point (the client-side visual-registration signal). - _dbgOccCount = _occRoot != null ? _occRoot.transform.childCount : 0; + // In live-occluder mode the proxies ARE the room's meshes (no OccluderProxies container), + // so report those — an honest 0 here would read as the latent zero-occluder path. + _dbgOccCount = _liveRoomOccluding ? _dbgLiveRoomRenderers + : (_occRoot != null ? _occRoot.transform.childCount : 0); _dbgPlateLocMatch = (_plateBoxesLocId == _locId); if (!string.IsNullOrEmpty(_qaActorId)) { @@ -2940,6 +2977,9 @@ struct QaCmd { public bool cell; public int c; public int r; public float vx; pu volatile bool _dbgPlateLocMatch; volatile bool _dbgActorValid; volatile float _dbgActorVX, _dbgActorVY; + // #1793 Day 3 fps read: accumulated on the main thread in Update, published once a second. + int _fpsFrames; float _fpsElapsed; + volatile float _dbgFps = -1f; // -1 => no full second sampled yet (never a misleading 0) volatile string _qaActorId = ""; // first cast token id (set on ApplyJson, main thread) int _qaShotCounter; // monotonic /shot id -> numbered files, no overwrite races volatile string _qaShotPathNext; // the path the NEXT countdown capture writes @@ -3015,7 +3055,15 @@ void QaListenLoop() // #1522: parley panel open/closed, so the journey gate can assert the panel is // dismissed after a cross_door without reading pixels. Unconditional (outside the // camera-pose block) — an absent field means an OLD build, never "closed". - .Append(",\"parleyOpen\":").Append(_parleyOpen ? "true" : "false"); + .Append(",\"parleyOpen\":").Append(_parleyOpen ? "true" : "false") + // #1793 Day 3 (rooms-are-the-scene): which live room is instantiated, how many + // renderers it put in the scene, and the player's fps. Unconditional (outside the + // camera-pose block) so a gate can tell "no live room" from "old build" by absence. + .Append(",\"liveRoom\":\"").Append(_dbgLiveRoom).Append("\"") + .Append(",\"liveRoomMode\":\"").Append(_dbgLiveRoomMode).Append("\"") + .Append(",\"liveRoomRenderers\":").Append(_dbgLiveRoomRenderers) + .Append(",\"grade\":").Append(_dbgGrade ? "true" : "false") + .Append(",\"fps\":").Append(_dbgFps.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)); if (_dbgCamValid) // #1583: camera pose for qa/walk_test.py (omitted on an old build) { var ic = System.Globalization.CultureInfo.InvariantCulture; @@ -4095,6 +4143,13 @@ void LoadPlateManifest() // VFX-ANCHORS: OPTIONAL `effects`:[{type, cell:[c,r], scale?, y?}] anchored VFX for this plate. if (row.ContainsKey("boxes") && row["boxes"] is string bp && !string.IsNullOrEmpty(bp)) pe.boxesPath = bp; // UNIFY-THE-FRAMES: occluder boxes sidecar + // ROOMS-ARE-THE-SCENE: `liveRoom` (baked prefab id) + the strategy-D `liveRoomKeepBackdrop`. + if (row.ContainsKey("liveRoom") && row["liveRoom"] is string lr && !string.IsNullOrEmpty(lr)) + pe.liveRoom = lr; + if (row.ContainsKey("liveRoomKeepBackdrop") && row["liveRoomKeepBackdrop"] is bool lkb) + pe.liveRoomKeepBackdrop = lkb; + if (row.ContainsKey("liveRoomMode") && row["liveRoomMode"] is string lrm && !string.IsNullOrEmpty(lrm)) + pe.liveRoomMode = lrm; if (row.ContainsKey("effects") && row["effects"] is System.Collections.Generic.List fx) { pe.effects = new System.Collections.Generic.List(); @@ -4259,6 +4314,9 @@ bool ApplyPlate(PlateEntry entry) else { oh = (cam != null && cam.orthographic) ? cam.orthographicSize * 2f : bd.transform.localScale.y; ow = oh * ((float)tex.width / tex.height); } var ls = bd.transform.localScale; bd.transform.localScale = new Vector3(ow, oh, ls.z == 0f ? 1f : ls.z); + // ROOMS-ARE-THE-SCENE (#1793 Day 3): swap the LIVE room in (or out) now that the camera rig and the + // backdrop quad are final — ApplyRoom decides whether the painted plate above is what the player sees. + ApplyRoom(entry, bd); Debug.Log("[CSC] plate swapped -> " + entry.plate + " (" + tex.width + "x" + tex.height + ", loc=" + _locId + ")"); // #1585/#1647 painted-door hotspots: record THIS plate's pixel dims (the space the hotspot px are // measured in) + its hotspot set, then (re)build the arch glow markers. The camera rig above is final, @@ -4337,6 +4395,121 @@ bool ApplyPlate(PlateEntry entry) return true; } + // ---- ROOMS-ARE-THE-SCENE: the live kit room (#1793 Day 3) ------------------------------------------- + + GameObject _liveRoomRoot; // the LiveRoom_ root this client owns (null => plate room) + bool _liveRoomOccluding; // strategy D: the live meshes replace the sidecar box proxies + volatile string _dbgLiveRoom = ""; // /debug: the live room id currently instantiated ("" => none) + volatile string _dbgLiveRoomMode = ""; // /debug: "visible" | "occluder" ("" => no live room) + volatile int _dbgLiveRoomRenderers; // /debug: renderer count under that root (0 => nothing shipped) + volatile bool _dbgGrade; // /debug: the baked camera grade is applied on Camera.main + + // Instantiate the edit-time-baked live room for this plate entry under a fresh `LiveRoom_` root, and + // disable the painted backdrop behind it (strategy B — the room IS the scene). The prefab carries the kit + // geometry, the PainterlyRoom materials and a PainterlyRoomLights component that re-arms the shader's fire + // globals in the player. Called from ApplyPlate on EVERY swap, so entering a plate-only room tears the + // previous live room down and restores the backdrop — no manifest entry can leak a room into the next one. + // + // Deliberately NOT touched here: the occluder path. `boxes` (the kit-derived sidecar) remains the single + // source of the depth proxies, so what masks an actor is identical whether the room is painted or live. + void ApplyRoom(PlateEntry entry, GameObject backdrop) + { + // Tear down whatever is standing — the field first, then a defensive sweep for a root left by an + // earlier client instance / a domain reload (Destroy is deferred, so re-entry must not double-count). + if (_liveRoomRoot != null) { Object.Destroy(_liveRoomRoot); _liveRoomRoot = null; } + foreach (var go in UnityEngine.SceneManagement.SceneManager.GetActiveScene().GetRootGameObjects()) + if (go != null && go.name.StartsWith("LiveRoom_", System.StringComparison.Ordinal)) Object.Destroy(go); + _liveRoomOccluding = false; + _dbgLiveRoom = ""; _dbgLiveRoomMode = ""; _dbgLiveRoomRenderers = 0; + + var brend = backdrop != null ? backdrop.GetComponent() : null; + if (entry == null || string.IsNullOrEmpty(entry.liveRoom)) + { + // Plate room: the backdrop is the picture and the grade travels with the live room, not the scene. + if (brend != null) brend.enabled = true; + PainterlyRoomLights.DisableGrade(Camera.main); _dbgGrade = false; + return; + } + + var prefab = Resources.Load("LiveRooms/" + entry.liveRoom); + if (prefab == null) + { + // A missing prefab must degrade to the painted plate, never to an empty black room. + Debug.LogWarning("[LiveRoom] Resources/LiveRooms/" + entry.liveRoom + " not found — keeping the painted plate"); + if (brend != null) brend.enabled = true; + PainterlyRoomLights.DisableGrade(Camera.main); _dbgGrade = false; + return; + } + bool occluder = entry.liveRoomMode == "occluder"; + var root = new GameObject("LiveRoom_" + entry.liveRoom); + root.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity); + var inst = Object.Instantiate(prefab, Vector3.zero, Quaternion.identity, root.transform); + inst.name = prefab.name; // no "(Clone)" in the shipped hierarchy + inst.transform.localPosition = Vector3.zero; // the kit bakes world-space; keep it at origin + inst.transform.localRotation = Quaternion.identity; + _liveRoomRoot = root; + + int swapped = 0, floors = 0; + if (occluder) + { + // STRATEGY D. The plate carries the picture; the room carries the MASK. Re-material every mesh to + // the SAME WorldOS/OccluderDepth material the box proxies use (ColorMask 0 + ZWrite, queue 1990), + // so the walk-behind pass at 1995 tests against depth written by the real painted geometry rather + // than by a coarse box. A missing shader degrades to the box proxies rather than to a visible room. + var omat = EnsureOccluderMaterial(); + if (omat == null) + { + Debug.LogWarning("[LiveRoom] " + entry.liveRoom + ": WorldOS/OccluderDepth unavailable — falling back to the sidecar box proxies"); + Object.Destroy(root); _liveRoomRoot = null; + if (brend != null) brend.enabled = true; + PainterlyRoomLights.DisableGrade(Camera.main); _dbgGrade = false; + return; + } + foreach (var l in root.GetComponentsInChildren(true)) l.enabled = false; // the plate is already lit + var prl = inst.GetComponent(); + if (prl != null) prl.enabled = false; // OnDisable zeroes _WOSFireCount: no fire term, no grade + foreach (var r in root.GetComponentsInChildren(true)) + { + // The floor never occludes an actor (the same rule the box sidecar applies to kind=="floor"), + // and a floor quad under the actor's own feet would fight the walk-behind depth test there. + if (IsUnderGroup(r.transform, root.transform, "Floor")) { r.enabled = false; floors++; continue; } + var ms = r.sharedMaterials; + for (int i = 0; i < ms.Length; i++) ms[i] = omat; + r.sharedMaterials = ms; + r.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + r.receiveShadows = false; + swapped++; + } + _liveRoomOccluding = true; + } + + _dbgLiveRoom = entry.liveRoom; + _dbgLiveRoomMode = occluder ? "occluder" : "visible"; + _dbgLiveRoomRenderers = root.GetComponentsInChildren(true).Length; + // Strategy B hides the plate; strategy D (and `liveRoomKeepBackdrop`) keeps it painted behind the room. + if (brend != null) brend.enabled = occluder || entry.liveRoomKeepBackdrop; + // #1793 Day 3b: the beauty panel judged a GRADED editor frame, so a VISIBLE live room re-applies the + // grade baked onto its prefab. An occluder room shows no live pixels, so it must not re-grade the plate. + if (!occluder) + { + var prl = inst.GetComponent(); + _dbgGrade = prl != null && PainterlyRoomLights.ApplyGrade(Camera.main, prl.grade); + if (prl != null && !_dbgGrade) + Debug.LogWarning("[LiveRoom] " + entry.liveRoom + ": camera grade NOT applied (BeautifyEffect.Beautify not in this build)"); + } + else { PainterlyRoomLights.DisableGrade(Camera.main); _dbgGrade = false; } + Debug.Log("[LiveRoom] " + entry.liveRoom + " applied: " + _dbgLiveRoomRenderers + " renderers" + + " mode=" + _dbgLiveRoomMode + (occluder ? " (occluders=" + swapped + ", floor renderers off=" + floors + ")" : "") + + " grade=" + _dbgGrade + (brend != null && brend.enabled ? " backdrop=on" : " backdrop=off")); + } + + // True when `t` sits under a child of `root` named `group` (the kit's [Floor]/[Walls]/... grouping). + static bool IsUnderGroup(Transform t, Transform root, string group) + { + for (var p = t; p != null && p != root; p = p.parent) if (p.name == group) return true; + return false; + } + // ---- VFX-ANCHORS: per-plate anchored presentation effects (fire / embers / fireflies) ---------------- // Parse the OPTIONAL StreamingAssets/effects_registry.json ({version, effects:{:{prefab}}}) into a diff --git a/extensions/renderers/unity/scripts/PainterlyRoomLights.cs b/extensions/renderers/unity/scripts/PainterlyRoomLights.cs new file mode 100644 index 00000000..0e593ebd --- /dev/null +++ b/extensions/renderers/unity/scripts/PainterlyRoomLights.cs @@ -0,0 +1,208 @@ +using UnityEngine; + +/// +/// LIVE-ROOM fire globals (#1793 Day 3, "the room is the scene"). +/// +/// The WorldOS/PainterlyRoom shader takes its point-light term from three SHADER GLOBALS +/// (_WOSFirePos[4] / _WOSFireColor[4] / _WOSFireCount) rather than from Unity's own light loop: +/// Unity demotes ForcePixel lights past `pixelLightCount` to VERTEX lights, and the demoted cool key +/// leaked a blue wash across the crypt, so the vertex-light term was removed from the shader on +/// purpose (Day 2). In the EDITOR those globals are set by the knob script; in the PLAYER nothing +/// sets them, so a baked live room would render with `_WOSFireCount == 0` (flat, fire-less). +/// +/// This component is baked onto the live-room prefab root by +/// `Tools/WorldOS/Kit/Bake Live Room Prefab` (build_room_kit.cs) with the values CAPTURED FROM THE +/// SCENE at bake time, so the player never depends on light-discovery order — or on lights at all. +/// The serialized fields are the source of truth; `Resources/LiveRooms/<roomId>.json` is a +/// human-readable fallback for a prefab baked before the fields existed / edited by hand. +/// +/// Runtime assembly ONLY — no UnityEditor usage (this ships inside the player). +/// +[DisallowMultipleComponent] +public class PainterlyRoomLights : MonoBehaviour +{ + // Shader global names — must match extensions/renderers/unity/shaders/PainterlyRoom.shader. + public const string FirePosGlobal = "_WOSFirePos"; + public const string FireColorGlobal = "_WOSFireColor"; + public const string FireCountGlobal = "_WOSFireCount"; + // The shader declares float4 _WOSFirePos[4] / _WOSFireColor[4]. SetGlobalVectorArray fixes the + // array size on first use, so ALWAYS submit exactly this many elements (padding with zeros) — + // a short array would silently resize the global and mismatch the declaration. + public const int MaxFires = 4; + + [Tooltip("Room id this prefab was baked for (also the Resources/LiveRooms/.json fallback key).")] + public string roomId = ""; + [Tooltip("xyz = world position of the fire, w = falloff range.")] + public Vector4[] firePos; + [Tooltip("rgb = light colour x gain (the painted fire's contribution), a unused.")] + public Vector4[] fireColor; + [Tooltip("How many entries of the arrays above are live (0 => the shader's fire term is off).")] + public int fireCount; + + /// JSON sidecar shape (JsonUtility — no parser dependency in the runtime assembly). + [System.Serializable] + public class FireGlobals + { + public string id; + public int count; + public Vector4[] pos; + public Vector4[] color; + public CameraGrade grade; + } + + // ---- camera grade (#1793 Day 3b) -------------------------------------------------------------- + // The blind beauty panel judged a GRADED editor frame: the crypt's room-of-record capture goes + // through a Beautify component that lives only in the editor scene's memory. The built player must + // reproduce that grade or it ships a different picture than the one that passed the panel. + // The values are BAKED (read off the editor camera when present) and applied by the client when a + // live room is instantiated, so the grade travels with the room rather than with the scene. + [System.Serializable] + public class CameraGrade + { + public bool bloom = true; + public float bloomIntensity = 1.1f; + public float bloomThreshold = 0.9f; + public float contrast = 1.14f; + public float saturate = 0.05f; + public float brightness = 0.98f; + public float sharpen = 2.5f; + public bool vignetting = true; + public float vignettingFade = 0.55f; + public float dither = 0.02f; + } + + [Tooltip("Post grade the room was judged under; applied to Camera.main when the room is instantiated.")] + public CameraGrade grade = new CameraGrade(); + + // Beautify ships as a third-party asset compiled into Assembly-CSharp. Reflection (not a direct + // reference) so this file — and everything that calls it — still COMPILES in a project where the + // asset is absent, and so a missing grade degrades to "ungraded", never to a build error. + public const string BeautifyTypeName = "BeautifyEffect.Beautify, Assembly-CSharp"; + static System.Type _beautify; static bool _beautifyResolved; + public static System.Type BeautifyType() + { + if (_beautifyResolved) return _beautify; + _beautifyResolved = true; + _beautify = System.Type.GetType(BeautifyTypeName); + if (_beautify == null) + foreach (var asm in System.AppDomain.CurrentDomain.GetAssemblies()) + { _beautify = asm.GetType("BeautifyEffect.Beautify"); if (_beautify != null) break; } + return _beautify; + } + + static bool SetMember(object target, string name, object value) + { + var t = target.GetType(); + var pi = t.GetProperty(name); + if (pi != null && pi.CanWrite) { pi.SetValue(target, System.Convert.ChangeType(value, pi.PropertyType), null); return true; } + var fi = t.GetField(name); + if (fi != null) { fi.SetValue(target, System.Convert.ChangeType(value, fi.FieldType)); return true; } + return false; + } + + static bool TryGetMember(object target, string name, out object value) + { + value = null; + var t = target.GetType(); + var pi = t.GetProperty(name); + if (pi != null && pi.CanRead) { value = pi.GetValue(target, null); return true; } + var fi = t.GetField(name); + if (fi != null) { value = fi.GetValue(target); return true; } + return false; + } + + /// Get-or-add the Beautify component on `cam` and push `g` onto it. False when the asset is + /// absent (or the camera is null) — the caller then reports an UNGRADED player rather than pretending. + public static bool ApplyGrade(Camera cam, CameraGrade g) + { + if (cam == null || g == null) return false; + var t = BeautifyType(); if (t == null) return false; + var comp = cam.GetComponent(t) ?? cam.gameObject.AddComponent(t); + if (comp == null) return false; + SetMember(comp, "bloom", g.bloom); + SetMember(comp, "bloomIntensity", g.bloomIntensity); + SetMember(comp, "bloomThreshold", g.bloomThreshold); + SetMember(comp, "contrast", g.contrast); + SetMember(comp, "saturate", g.saturate); + SetMember(comp, "brightness", g.brightness); + SetMember(comp, "sharpen", g.sharpen); + SetMember(comp, "vignetting", g.vignetting); + SetMember(comp, "vignettingFade", g.vignettingFade); + SetMember(comp, "dither", g.dither); + var beh = comp as Behaviour; if (beh != null) beh.enabled = true; + return true; + } + + /// Switch the grade off (leaving a live room). Never removes the component — a second + /// enable must not re-run the asset's Awake/OnEnable cost mid-session. + public static void DisableGrade(Camera cam) + { + if (cam == null) return; + var t = BeautifyType(); if (t == null) return; + var beh = cam.GetComponent(t) as Behaviour; + if (beh != null) beh.enabled = false; + } + + /// Read the LIVE grade off a camera (bake time). False => nothing read; `into` is untouched. + public static bool ReadGrade(Camera cam, CameraGrade into) + { + if (cam == null || into == null) return false; + var t = BeautifyType(); if (t == null) return false; + var comp = cam.GetComponent(t); if (comp == null) return false; + object v; + if (TryGetMember(comp, "bloom", out v)) into.bloom = System.Convert.ToBoolean(v); + if (TryGetMember(comp, "bloomIntensity", out v)) into.bloomIntensity = System.Convert.ToSingle(v); + if (TryGetMember(comp, "bloomThreshold", out v)) into.bloomThreshold = System.Convert.ToSingle(v); + if (TryGetMember(comp, "contrast", out v)) into.contrast = System.Convert.ToSingle(v); + if (TryGetMember(comp, "saturate", out v)) into.saturate = System.Convert.ToSingle(v); + if (TryGetMember(comp, "brightness", out v)) into.brightness = System.Convert.ToSingle(v); + if (TryGetMember(comp, "sharpen", out v)) into.sharpen = System.Convert.ToSingle(v); + if (TryGetMember(comp, "vignetting", out v)) into.vignetting = System.Convert.ToBoolean(v); + if (TryGetMember(comp, "vignettingFade", out v)) into.vignettingFade = System.Convert.ToSingle(v); + if (TryGetMember(comp, "dither", out v)) into.dither = System.Convert.ToSingle(v); + return true; + } + + void OnEnable() + { + if (fireCount <= 0 || firePos == null || fireColor == null) LoadFromResources(); + Apply(); + } + + // A room that is torn down (plate swap / room change) must not leave its fires lighting the next + // room: zeroing the COUNT is enough (the shader's loop breaks at f >= _WOSFireCount). + void OnDisable() { Shader.SetGlobalFloat(FireCountGlobal, 0f); } + + /// Push the baked globals to the shader. Public so a caller can re-arm them after + /// anything else (a capture rig, another room) has overwritten the globals. + public void Apply() + { + var pos = new Vector4[MaxFires]; + var col = new Vector4[MaxFires]; + int n = Mathf.Clamp(fireCount, 0, MaxFires); + for (int i = 0; i < n; i++) + { + if (firePos != null && i < firePos.Length) pos[i] = firePos[i]; + if (fireColor != null && i < fireColor.Length) col[i] = fireColor[i]; + } + Shader.SetGlobalVectorArray(FirePosGlobal, pos); + Shader.SetGlobalVectorArray(FireColorGlobal, col); + Shader.SetGlobalFloat(FireCountGlobal, n); + } + + // Fallback only (see the class doc): Resources/LiveRooms/.json beside the prefab. + void LoadFromResources() + { + if (string.IsNullOrEmpty(roomId)) return; + var ta = Resources.Load("LiveRooms/" + roomId); + if (ta == null) { Debug.LogWarning("[PainterlyRoomLights] no baked fires and no Resources/LiveRooms/" + roomId + ".json"); return; } + try + { + var g = JsonUtility.FromJson(ta.text); + if (g == null) return; + firePos = g.pos; fireColor = g.color; fireCount = g.count; + if (g.grade != null) grade = g.grade; + } + catch (System.Exception e) { Debug.LogWarning("[PainterlyRoomLights] " + roomId + ".json parse: " + e.Message); } + } +} diff --git a/extensions/renderers/unity/scripts/build_room_kit.cs b/extensions/renderers/unity/scripts/build_room_kit.cs index bc5a458e..77b52d31 100644 --- a/extensions/renderers/unity/scripts/build_room_kit.cs +++ b/extensions/renderers/unity/scripts/build_room_kit.cs @@ -144,6 +144,8 @@ static string CaptureDir() => internal const string KitHelperFire = "KitRoom_Fire"; internal const string KitHelperTombGlow = "KitRoom_TombGlow"; internal const string KitHelperCoolKey = "KitRoom_CoolKey"; + // the token BuildMacOSPlayer byte-scans for; the live-room bake must leave none of it behind. + internal const string QAKitPrefix = "KitRoom_"; // Occluder-sidecar export DERIVED from the built kit scene: per-mass true renderer bounds instead of // hand-authored chunky volumes. The felt-truth failure this kills (owner playtest 2026-07-23): the @@ -379,6 +381,7 @@ public static void BuildRoom() // ── PROPS: pillars / sarcophagus / braziers / kit-or-fallback ────────────────────────────── var propParent = Child(root, "Props"); + _propKindById.Clear(); var braziers = new List(); // world positions for the fire-anchor point lights Vector3 sarcGlowPos = Vector3.zero; bool haveSarcGlow = false; // r3: warm centre-glow over the tomb if (props != null) @@ -389,6 +392,7 @@ public static void BuildRoom() string kind = (GetStr(p, "kind", "prop") ?? "prop").ToLowerInvariant(); if (kind == "wall_run") continue; // handled above string pid = GetStr(p, "id", "prop") ?? "prop"; + _propKindById[pid] = kind; // geometry kind = texture-mapping truth (Day 2) var cells = GetList(p, "cells"); if (cells == null || cells.Count == 0) continue; Footprint fp = FootprintOf(cells, cols, rows); if (!fp.valid) continue; @@ -627,6 +631,8 @@ public static void BuildRoom() RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Flat; RenderSettings.ambientLight = new Color(0.20f, 0.22f, 0.30f); // r3 cool stone ambient fill + ApplyPainterlyRoomTextures(root); + // ── camera (paint contract, 1344x768 fit) so the SAVED scene carries the plate-contract rig ── var cam = MainCam(create: true); SetupContractCamera(cam, cols, rows, camFit, 1344f / 768f); @@ -656,6 +662,90 @@ public static void BuildRoom() $"kit_props={nKit} fallbacks={nFallback} material_fixes={nMatFix}"); } + /// propId → geometry kind for the room being built (filled by BuildRoom's props loop). The placed + /// object names carry the propId but not always the kind (e.g. "urn_goods_fallback"), so the texture mapping + /// resolves the kind from the geometry first and only falls back to the name. + static readonly Dictionary _propKindById = new Dictionary(); + + static string GeometryKindFor(string placedName) + { + string best = null; int bestLen = -1; + foreach (var kv in _propKindById) + if (placedName.StartsWith(kv.Key, StringComparison.OrdinalIgnoreCase) && kv.Key.Length > bestLen) { best = kv.Value; bestLen = kv.Key.Length; } + return best; + } + + static void ApplyPainterlyRoomTextures(GameObject root) + { + string raw = Environment.GetEnvironmentVariable("WORLDOS_ROOM_TEXTURES"); + if (string.IsNullOrWhiteSpace(raw)) return; + string dir = raw.Replace('\\', '/').TrimEnd('/'); + string assets = Application.dataPath.Replace('\\', '/'); + if (Path.IsPathRooted(dir) && dir.StartsWith(assets + "/", StringComparison.OrdinalIgnoreCase)) + dir = "Assets" + dir.Substring(assets.Length); + if (dir != "Assets" && !dir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { Debug.LogWarning($"[BuildRoomKit] WORLDOS_ROOM_TEXTURES must be under Assets: {raw}"); return; } + string diskDir = Path.Combine(Directory.GetParent(Application.dataPath).FullName, dir); + if (!Directory.Exists(diskDir)) + { Debug.LogWarning($"[BuildRoomKit] WORLDOS_ROOM_TEXTURES directory not found: {dir}"); return; } + + var shader = Shader.Find("WorldOS/PainterlyRoom"); + if (shader == null) { Debug.LogError("[BuildRoomKit] WorldOS/PainterlyRoom shader not found."); return; } + var cache = new Dictionary(); + var kinds = new HashSet(); + int nRenderers = 0, nMaterials = 0; + Transform lights = root.transform.Find("Lights"); + foreach (var rend in root.GetComponentsInChildren(true)) + { + if (rend == null || (lights != null && rend.transform.IsChildOf(lights))) continue; + bool skip = false; + foreach (var old in rend.sharedMaterials) + if (old != null && old.shader != null && old.shader.name == "Unlit/Color") { skip = true; break; } + if (skip) continue; + + Transform placed = rend.transform; + while (placed.parent != null && placed.parent.parent != root.transform) placed = placed.parent; + // Placed names are "_" (e.g. gate_brazier_l_Brazier, stone_pillar_ne_Pillar), so the + // material class is read from the WHOLE lowercased name, never from the first '_' token. + string kind = placed.name.ToLowerInvariant(); + string geoKind = GeometryKindFor(placed.name); + if (!string.IsNullOrEmpty(geoKind)) kind = geoKind.ToLowerInvariant() + "|" + kind; // kind first, name as fallback + // Light sources keep their kit materials (emissive bowls/flames are the beacons the plate is judged by). + if (kind.Contains("brazier") || kind.Contains("torch") || kind.Contains("candle") || kind.Contains("fire") + || kind.Contains("flame") || kind.Contains("lamp") || kind.Contains("glow") || kind.Contains("ember")) continue; + string file = RoomTextureFile(kind), assetPath = dir + "/" + file; + if (!cache.TryGetValue(assetPath, out Material mat)) + { + var tex = AssetDatabase.LoadAssetAtPath(assetPath); + if (tex == null) Debug.LogWarning($"[BuildRoomKit] PainterlyRoom texture not found: {assetPath}"); + else + { + mat = new Material(shader) { name = "PainterlyRoom_" + Path.GetFileNameWithoutExtension(file) }; + mat.SetTexture("_MainTex", tex); + mat.SetFloat("_TriScale", file == "floor.png" || file == "wall.png" || file == "pillar.png" ? 2f : 1f); + nMaterials++; + } + cache[assetPath] = mat; + } + if (mat == null) continue; + var assigned = new Material[rend.sharedMaterials.Length]; + for (int i = 0; i < assigned.Length; i++) assigned[i] = mat; + rend.sharedMaterials = assigned; nRenderers++; kinds.Add(kind); + } + Debug.Log($"[BuildRoomKit] PainterlyRoom textures from {dir}: {nRenderers} renderers, {nMaterials} materials, {kinds.Count} kinds"); + } + + static string RoomTextureFile(string kind) + { + if (kind.Contains("pillar") || kind.Contains("post") || kind.Contains("column")) return "pillar.png"; + if (kind.Contains("floor")) return "floor.png"; + if (kind.Contains("sarcophagus") || kind.Contains("tomb") || kind.Contains("altar") || kind.Contains("dais") || kind.Contains("throne")) return "tomb.png"; + if (kind.Contains("rubble") || kind.Contains("rock") || kind.Contains("debris")) return "rubble.png"; + if (kind.Contains("barrel") || kind.Contains("crate") || kind.Contains("table") || kind.Contains("bench") || kind.Contains("bar") || kind.Contains("counter") || kind.Contains("shelf") || kind.Contains("wood")) return "wood.png"; + if (kind.Contains("wall") || kind.Contains("stone") || kind.Contains("ruin") || kind.Contains("jamb") || kind.Contains("lintel") || kind.Contains("arch") || kind.Contains("door") || kind.Contains("parapet")) return "wall.png"; + return "wall.png"; + } + // ════════════════════════════════════════════════════════════════════════════════════════════════ // CAPTURE // ════════════════════════════════════════════════════════════════════════════════════════════════ @@ -1265,6 +1355,176 @@ static void RenderToPng(Camera cam, int W, int H, string outPath) } } + + // ════════════════════════════════════════════════════════════════════════════════════════════════ + // BAKE — the live room ships in the player (#1793 Day 3, "the room is the scene") + // ════════════════════════════════════════════════════════════════════════════════════════════════ + // Turn the kit room standing in the editor scene (after BuildRoom + the room's knob set) into a + // PREFAB the built player can Resources.Load and instantiate in front of / instead of the painted + // plate (CombatSurfaceClient.ApplyRoom). Three constraints shape every step below: + // + // 1. NEVER mutate the QA root. The scene's KitRoom_ is the capture/scoring subject and the + // canonical scene is dirty-in-memory on purpose; everything here happens on a DUPLICATE that is + // destroyed again before this method returns. The scene is never saved. + // 2. NOTHING in the prefab may be named `KitRoom_*`. BuildMacOSPlayer byte-scans the built data for + // that token and REFUSES the build (that gate exists because kit rooms shipped inside a player + // three times). The copy and every descendant are renamed KitRoom_ -> LiveRoom_. + // 3. NO in-memory materials may survive. The kit builds its materials at runtime (knobs.py then + // mutates them), so they have no asset path — a prefab saved with those references would ship + // with MISSING materials (magenta). Every material without an asset path is written under + // Assets/Resources/LiveRooms// and re-pointed before the prefab is saved. + // + // The fire globals the PainterlyRoom shader reads are captured HERE (from the live shader globals + // when the knob script has set them, else re-derived from the room's point lights) and baked onto a + // PainterlyRoomLights component + a .json sidecar, so the player never depends on light order. + const string LiveRoomPrefix = "LiveRoom_"; + const string LiveRoomsDir = "Assets/Resources/LiveRooms"; + // knobs.py contract: w of _WOSFirePos = falloff range, _WOSFireColor = light.color * gain. Only used + // on the FALLBACK path (globals not set, e.g. after a script recompile cleared them). + static float FireRange() => EnvFloat("WORLDOS_FIRE_RANGE", 11f); + static float FireGain() => EnvFloat("WORLDOS_FIRE_GAIN", 3f); + static float EnvFloat(string k, float def) + { + var s = Environment.GetEnvironmentVariable(k); + return float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : def; + } + + [MenuItem("Tools/WorldOS/Kit/Bake Live Room Prefab")] + public static void BakeLiveRoomPrefab() + { + string geoPath = GeoPath(); + string roomId = Environment.GetEnvironmentVariable("WORLDOS_KIT_ROOM_ID"); + if (string.IsNullOrEmpty(roomId) && File.Exists(geoPath)) + { + var geo = MiniJson.Parse(File.ReadAllText(geoPath)) as Dictionary; + if (geo != null) roomId = RoomId(geo, geoPath); + } + roomId = string.IsNullOrEmpty(roomId) ? "room" : DisambiguateRoomId(Sanitize(roomId)); + + var src = GameObject.Find("KitRoom_" + roomId) ?? FindAnyKitRoom(); + if (src == null) { Debug.LogError("[BakeLiveRoom] no KitRoom_* root in the scene — run Build Room From Kit (+ the room's knob set) first."); return; } + + // (1) duplicate; the QA root is never touched from here on. + var copy = UnityEngine.Object.Instantiate(src); + copy.name = LiveRoomPrefix + roomId; + copy.transform.SetPositionAndRotation(src.transform.position, src.transform.rotation); + copy.transform.localScale = src.transform.localScale; + try + { + // The cast proxy (fighter + monsters) is a LOOK reference for the editor capture, never room + // geometry — the player spawns its own actors from the registry. Drop it from the copy. + int cast = 0; + foreach (var t in copy.GetComponentsInChildren(true)) + if (t != null && t != copy.transform && t.name == "Cast") { UnityEngine.Object.DestroyImmediate(t.gameObject); cast++; } + + // (2) rename: no descendant may carry the KitRoom_ token the build gate scans for. + int renamed = 0; + foreach (var t in copy.GetComponentsInChildren(true)) + if (t != null && t.name.Contains(QAKitPrefix)) { t.name = t.name.Replace(QAKitPrefix, LiveRoomPrefix); renamed++; } + + // (3) materials -> assets, then re-point every renderer at the saved copies. + Directory.CreateDirectory(LiveRoomsDir + "/" + roomId); + AssetDatabase.Refresh(); + var saved = new Dictionary(); + var seen = new List(); + int renderers = 0; + foreach (var r in copy.GetComponentsInChildren(true)) + { + renderers++; + var mats = r.sharedMaterials; + for (int i = 0; i < mats.Length; i++) + { + var m = mats[i]; + if (m == null) continue; + if (!seen.Contains(m)) seen.Add(m); + if (!string.IsNullOrEmpty(AssetDatabase.GetAssetPath(m))) continue; // already an asset — keep the reference + if (!saved.TryGetValue(m, out var asset)) + { + // index-suffixed (not GenerateUniqueAssetPath) so the file set is deterministic + // across bakes and never carries the " 1" spaces Unity's uniquifier appends — + // the kit's braziers legitimately hold a dozen distinct materials all named "Standard". + string mp = $"{LiveRoomsDir}/{roomId}/mat_{saved.Count:00}_{Sanitize(m.name)}.mat"; + asset = new Material(m); + asset.name = Path.GetFileNameWithoutExtension(mp); + AssetDatabase.CreateAsset(asset, mp); + saved[m] = asset; + } + mats[i] = asset; + } + r.sharedMaterials = mats; + } + AssetDatabase.SaveAssets(); + + // (4) fire globals: prefer what the shader is ACTUALLY rendering with (the knob set the room + // of record was captured under); fall back to re-deriving them from the room's point lights + // the way knobs.py does when the globals are unset (a script recompile clears them). + int lights = 0; + foreach (var l in copy.GetComponentsInChildren(true)) lights++; + var pos = new Vector4[PainterlyRoomLights.MaxFires]; + var col = new Vector4[PainterlyRoomLights.MaxFires]; + int n = 0; + string fireSrc = "globals"; + float gcount = Shader.GetGlobalFloat(PainterlyRoomLights.FireCountGlobal); + var gpos = Shader.GetGlobalVectorArray(PainterlyRoomLights.FirePosGlobal); + var gcol = Shader.GetGlobalVectorArray(PainterlyRoomLights.FireColorGlobal); + if (gcount > 0f && gpos != null && gcol != null && gpos.Length >= (int)gcount && gcol.Length >= (int)gcount) + { + n = Mathf.Min((int)gcount, PainterlyRoomLights.MaxFires); + for (int i = 0; i < n; i++) { pos[i] = gpos[i]; col[i] = gcol[i]; } + } + else + { + fireSrc = "lights"; + float rng = FireRange(), gain = FireGain(); + foreach (var l in copy.GetComponentsInChildren(true)) + { + if (l == null || l.type != LightType.Point) continue; + if (l.name.Contains("CoolKey") || l.name.Contains("TombGlow")) continue; // key/ambient fills, not fires + if (n >= PainterlyRoomLights.MaxFires) break; + var p = l.transform.position; + pos[n] = new Vector4(p.x, p.y, p.z, rng); + col[n] = new Vector4(l.color.r * gain, l.color.g * gain, l.color.b * gain, 1f); + n++; + } + } + var prl = copy.GetComponent() ?? copy.AddComponent(); + prl.roomId = roomId; prl.firePos = pos; prl.fireColor = col; prl.fireCount = n; + + // (4b) CAMERA GRADE (#1793 Day 3b). The blind beauty panel judged a frame graded by the Beautify + // component that lives ONLY in this editor scene's memory — the player would otherwise ship an + // ungraded picture of the same room. Read the live values off the editor camera when the asset is + // present; otherwise bake the panel's declared block, so the prefab always names a grade. + var grade = new PainterlyRoomLights.CameraGrade(); + bool gradeRead = PainterlyRoomLights.ReadGrade(MainCam(create: false), grade); + prl.grade = grade; + + // (5) the human-readable sidecar (also the runtime fallback if the component is ever reset). + var fg = new PainterlyRoomLights.FireGlobals { id = roomId, count = n, pos = pos, color = col, grade = grade }; + string sidecarPath = $"{LiveRoomsDir}/{roomId}.json"; + File.WriteAllText(sidecarPath, JsonUtility.ToJson(fg, true)); + AssetDatabase.ImportAsset(sidecarPath, ImportAssetOptions.ForceUpdate); // -> a TextAsset the player can Resources.Load + + // (6) refuse to write a prefab that would red the build gate or ship missing materials. + foreach (var t in copy.GetComponentsInChildren(true)) + if (t.name.Contains(QAKitPrefix)) + { Debug.LogError("[BakeLiveRoom] ABORT: '" + t.name + "' still carries " + QAKitPrefix + " — the build scan would refuse this prefab."); return; } + foreach (var r in copy.GetComponentsInChildren(true)) + foreach (var m in r.sharedMaterials) + if (m != null && string.IsNullOrEmpty(AssetDatabase.GetAssetPath(m))) + { Debug.LogError("[BakeLiveRoom] ABORT: material '" + m.name + "' is still in-memory — the prefab would ship with a missing material."); return; } + + string prefabPath = $"{LiveRoomsDir}/{roomId}.prefab"; + var prefab = PrefabUtility.SaveAsPrefabAsset(copy, prefabPath, out bool ok); + AssetDatabase.SaveAssets(); + if (!ok || prefab == null) { Debug.LogError("[BakeLiveRoom] SaveAsPrefabAsset FAILED -> " + prefabPath); return; } + + Debug.Log($"[BakeLiveRoom] {roomId}: {renderers} renderers, {seen.Count} materials ({saved.Count} newly saved), " + + $"{lights} lights, {n} fires (src={fireSrc}), grade={(gradeRead ? "read-from-camera" : "defaults")}, " + + $"cast groups dropped={cast}, renamed={renamed} -> {prefabPath}"); + } + finally { UnityEngine.Object.DestroyImmediate(copy); } + } + static Camera MainCam(bool create) { Camera cam = Camera.main; diff --git a/extensions/renderers/unity/shaders/PainterlyRoom.shader b/extensions/renderers/unity/shaders/PainterlyRoom.shader new file mode 100644 index 00000000..dc268da3 --- /dev/null +++ b/extensions/renderers/unity/shaders/PainterlyRoom.shader @@ -0,0 +1,145 @@ +Shader "WorldOS/PainterlyRoom" +{ + Properties + { + _MainTex ("Albedo", 2D) = "white" {} + _Tint ("Tint", Color) = (1,1,1,1) + _TriScale ("World units per tile", Float) = 2.0 + _TriSharpness ("Blend sharpness", Range(1,8)) = 4 + _DetailTex ("Grime / detail (world-space, multiply)", 2D) = "white" {} + _GrimeStrength ("Grime strength", Range(0,1)) = 0 + _GrimeScale ("Grime tile scale", Float) = 3 + _GrimeHeight ("Grime fade height (world u)", Float) = 1.6 + _Desat ("Desaturate", Range(0,1)) = 0.25 + _KeyColor ("Key (hearth) Color", Color) = (1.0,0.604,0.271,1) + _AmbientColor ("Ambient Fill Color", Color) = (0.227,0.247,0.333,1) + _KeyDir ("Key Dir (world)", Vector) = (0.5,0.55,0.7,0) + _KeyStrength ("Key Strength", Range(0,3)) = 1.0 + _RimStrength ("Rim Strength", Range(0,2)) = 0.5 + _BounceStrength ("Warm Floor Bounce", Range(0,1)) = 0.2 + _Posterize ("Value Posterize Steps", Range(2,16)) = 6.0 + _BrushStrength ("Brush-Grain Strength", Range(0,1)) = 0.40 + _BrushScale ("Brush-Grain Scale", Range(2,60)) = 20.0 + _PaletteSnap ("Palette Snap (sat pull)", Range(0,1)) = 0.30 + _PaintLift ("Paint Value Lift", Range(0,0.3)) = 0.07 + _AmbientLift ("Ambient Lift (no black mass)", Range(0,0.5)) = 0.18 + _MaxLuma ("Max Luma (stay below hearth)", Range(0.3,1)) = 0.72 + _TermSharp ("Terminator Sharpness", Range(0,1)) = 0.5 + _CoolRimStrength ("Cool Rim (back-facing) Strength", Range(0,2)) = 0.55 + _CoolRimColor ("Cool Rim Color", Color) = (0.10,0.21,0.31,1) + _SceneLitSideTint ("Scene Lit-Side Tint (hearth amber)", Color) = (0.78,0.48,0.12,1) + _SceneShadowTint ("Scene Shadow-Side Tint (stone blue-grey)", Color) = (0.17,0.22,0.32,1) + _SceneContamination ("Scene Color Contamination 0..1", Range(0,0.5)) = 0.18 + _AtmDepth ("Atmospheric Wash 0..1", Range(0,1)) = 0.0 + _AtmColor ("Atmospheric Fog Color", Color) = (0.13,0.13,0.16,1) + } + SubShader + { + Tags { "Queue"="Geometry" "RenderType"="Opaque" } + Pass + { + Tags { "LightMode"="ForwardBase" } + ZWrite On + Cull Back + CGPROGRAM + #pragma target 3.0 + #pragma vertex vert + #pragma fragment frag + #pragma multi_compile_fwdbase + #include "UnityCG.cginc" + #include "Lighting.cginc" + #include "AutoLight.cginc" + + sampler2D _MainTex; sampler2D _DetailTex; float _GrimeStrength, _GrimeScale, _GrimeHeight; + fixed4 _Tint, _KeyColor, _AmbientColor, _CoolRimColor; + fixed4 _SceneLitSideTint, _SceneShadowTint, _AtmColor; + float4 _KeyDir; + float _TriScale, _TriSharpness, _Desat, _KeyStrength, _RimStrength, _BounceStrength; + float _Posterize, _BrushStrength, _BrushScale, _PaletteSnap, _PaintLift, _AmbientLift; + float _MaxLuma, _TermSharp, _CoolRimStrength, _SceneContamination, _AtmDepth; + + struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; }; + struct v2f { float4 pos : SV_POSITION; float3 wpos : TEXCOORD0; float3 normal : TEXCOORD1; float3 view : TEXCOORD2; }; + v2f vert(appdata v) + { + v2f o; o.pos = UnityObjectToClipPos(v.vertex); + o.wpos = mul(unity_ObjectToWorld, v.vertex).xyz; + o.normal = UnityObjectToWorldNormal(v.normal); + o.view = _WorldSpaceCameraPos - o.wpos; + return o; + } + float lum(fixed3 c) { return dot(c, fixed3(0.299,0.587,0.114)); } + float hash21(float2 p) { p=frac(p*float2(123.34,456.21)); p+=dot(p,p+45.32); return frac(p.x*p.y); } + float noise(float2 p) + { + float2 q=floor(p), f=frac(p), u=f*f*(3.0-2.0*f); + return lerp(lerp(hash21(q),hash21(q+float2(1,0)),u.x),lerp(hash21(q+float2(0,1)),hash21(q+1),u.x),u.y); + } + float3 triWeights(float3 n) + { + float3 w=pow(max(abs(n),0.0001),_TriSharpness); + return w/max(w.x+w.y+w.z,0.0001); + } + fixed3 triSample(float3 p, float3 w) + { + p /= max(_TriScale,0.0001); + fixed3 x=tex2D(_MainTex,p.zy).rgb; + fixed3 y=tex2D(_MainTex,p.xz).rgb; + fixed3 z=tex2D(_MainTex,p.xy).rgb; + return x*w.x+y*w.y+z*w.z; + } + // Explicit fire beacons (set as globals by the kit builder / capture payload: xyz = world pos, w = range; + // color.rgb = light colour * intensity). Independent of Unity's vertex-light plumbing, so the brazier + // pools land on the floor deterministically in Editor captures AND in the player. + float4 _WOSFirePos[4]; float4 _WOSFireColor[4]; float _WOSFireCount; + fixed3 pointLights(float3 p, float3 n) + { + fixed3 c=0; + for (int f=0;f<4;f++) + { + if (f>=_WOSFireCount) break; + float3 d=_WOSFirePos[f].xyz-p; float d2=max(dot(d,d),0.0001); float rng=max(_WOSFirePos[f].w,0.01); + float att=saturate(1.0-sqrt(d2)/rng); att*=att; + c+=_WOSFireColor[f].rgb*lerp(0.35,1.0,saturate(dot(n,d*rsqrt(d2))))*att; + } + // (Unity vertex-light term removed 2026-09-03: demoted ForcePixel lights leaked the blue CoolKey into unity_4Light*; + // point lighting for painterly room surfaces is owned by the explicit _WOSFire* globals.) + + return c; + } + fixed4 frag(v2f i) : SV_Target + { + float3 N=normalize(i.normal), V=normalize(i.view), L=normalize(_KeyDir.xyz), w=triWeights(N); + fixed3 albedo=triSample(i.wpos,w)*_Tint.rgb; float a=lum(albedo); albedo=lerp(albedo,a.xxx,_Desat); + // Day-3 lever: world-space grime multiply, strongest near the floor (contact dirt at wall bases / prop feet) + { float3 gp=i.wpos/_GrimeScale; float3 gw=triWeights(N); fixed3 g=tex2D(_DetailTex,gp.zy).rgb*gw.x+tex2D(_DetailTex,gp.xz).rgb*gw.y+tex2D(_DetailTex,gp.xy).rgb*gw.z; + float gl=dot(g,fixed3(0.3,0.59,0.11)); float hf=saturate(1.0-i.wpos.y/max(_GrimeHeight,0.01)); float grime=lerp(1.0,lerp(0.55,1.05,gl),_GrimeStrength*(0.35+0.65*hf)); albedo*=grime; } + float lambert=dot(N,L), lo=lerp(-0.25,0.18,_TermSharp), hi=lerp(0.55,0.34,_TermSharp); + float keyMask=smoothstep(lo,hi,lambert); + fixed3 key=lerp(fixed3(1,1,1),_KeyColor.rgb,0.92), cool=_AmbientColor.rgb*1.9+fixed3(0,0.02,0.10); + fixed3 lit=albedo*lerp(cool,key,keyMask)*lerp(0.72+_AmbientLift,1.05*_KeyStrength,keyMask); + lit+=albedo*pointLights(i.wpos,N); + lit+=albedo*((fixed3(0.16,0.14,0.13)+_KeyColor.rgb*0.06)*0.55)*saturate(-lambert*1.6); + lit+=albedo*_KeyColor.rgb*(keyMask*0.12+saturate(-N.y)*_BounceStrength); + float fres=pow(1.0-saturate(dot(N,V)),4.0); + lit+=_KeyColor.rgb*fres*saturate(dot(N,L))*_RimStrength*0.35; + lit+=_CoolRimColor.rgb*fres*saturate(-dot(N,L)+0.1)*_CoolRimStrength*0.55; + float v=max(lum(lit),0.0001), steps=max(2.0,_Posterize), sv=v*steps; + float vq=(floor(sv)+smoothstep(0.32,0.68,frac(sv)))/steps; + lit=(lit/v)*lerp(v,vq,0.78); + float scale=_BrushScale/max(_TriScale,0.0001); + float g=noise(i.wpos.zy*scale)*w.x+noise(i.wpos.xz*scale)*w.y+noise(i.wpos.xy*scale)*w.z; + float grain=(g-0.5)*2.0; lit*=1.0+grain*_BrushStrength*0.30; lit+=_PaintLift*grain*keyMask*0.6; + float lp=lum(lit); lit=lerp(lit,lp.xxx,_PaletteSnap*0.5); lit=lerp(lit,lit*lerp(cool,key,keyMask),_PaletteSnap*0.4); + lit=lerp(lit,lit*lerp(_SceneShadowTint.rgb,_SceneLitSideTint.rgb,keyMask),_SceneContamination); + float la=lum(lit), fv=lum(_AtmColor.rgb); + lit=lerp(lit,la.xxx,_AtmDepth*0.45); lit=lerp(lit,lerp(lit,fv.xxx,0.5),_AtmDepth*0.55); + lit=lerp(lit,_AtmColor.rgb+(lit-la),_AtmDepth*0.30); + float lf=lum(lit); if (lf>_MaxLuma) lit*=_MaxLuma/lf; + return fixed4(saturate(lit),1); + } + ENDCG + } + } + Fallback "Standard" +} diff --git a/qa/test_walk_static.py b/qa/test_walk_static.py index 1d74a71c..648e207c 100644 --- a/qa/test_walk_static.py +++ b/qa/test_walk_static.py @@ -118,6 +118,25 @@ def test_boxless_sidecar_fails(tmp_path): assert any("NO occluder volumes" in f for f in WS.lint_manifest_entry("x", entry, tmp_path)) +# --- ROOMS-ARE-THE-SCENE live-room keys (#1793 Day 3, red-first) ------------------------------------- +def test_unknown_live_room_mode_fails(): + """A misspelled mode falls through to "visible" in the client — a room gated as an occluder would + then ship as the picture. Fail it in CI instead.""" + fails = WS.lint_live_room("crypt", {"liveRoom": "crypt", "liveRoomMode": "occluders"}) + assert any("liveRoomMode" in f for f in fails) + + +def test_live_room_keys_without_live_room_fail(): + assert any("without `liveRoom`" in f + for f in WS.lint_live_room("x", {"liveRoomMode": "occluder"})) + + +def test_live_room_entry_passes(): + assert WS.lint_live_room("crypt", {"liveRoom": "crypt", "liveRoomMode": "occluder"}) == [] + assert WS.lint_live_room("crypt", {"liveRoom": "crypt"}) == [] + assert WS.lint_live_room("crypt", {}) == [] + + def test_prop_on_door_landing_fails(): """Prop footprints count as blocked even when the geometry does NOT fold them into walls (the generate_town convention) — a barrel on the landing must go red.""" diff --git a/qa/walk_static.py b/qa/walk_static.py index 1db204e5..166bff77 100644 --- a/qa/walk_static.py +++ b/qa/walk_static.py @@ -101,6 +101,33 @@ def lint_manifest_entry(key: str, entry: dict, unity_dir: Path) -> list: # footprint proxies — the room loses the exact occluders the plate was conditioned on. if not side.get("boxes"): fails.append(f"{key}: boxes sidecar has NO occluder volumes (empty/missing `boxes`)") + fails += lint_live_room(key, entry) + return fails + + +# ROOMS-ARE-THE-SCENE (#1793 Day 3): `liveRoom` names an edit-time-baked kit-room prefab the player +# instantiates (Resources/LiveRooms/.prefab). The prefab itself lives in the Unity project, not this +# repo, so CI cannot check it exists — what it CAN make impossible is the silent-typo class: a `liveRoomMode` +# the client does not understand falls through to "visible", which would ship the room as the PICTURE in a +# room that was gated as an occluder. Unknown/misspelled values fail loud here instead. +LIVE_ROOM_MODES = ("visible", "occluder") + + +def lint_live_room(key: str, entry: dict) -> list: + fails = [] + live = entry.get("liveRoom") + mode, keep = entry.get("liveRoomMode"), entry.get("liveRoomKeepBackdrop") + if live is None: + if mode is not None or keep is not None: + fails.append(f"{key}: liveRoomMode/liveRoomKeepBackdrop without `liveRoom` — the client never reads them") + return fails + if not isinstance(live, str) or not live.strip(): + fails.append(f"{key}: `liveRoom` must be a non-empty room id string") + if mode is not None and mode not in LIVE_ROOM_MODES: + fails.append(f"{key}: liveRoomMode={mode!r} is not one of {LIVE_ROOM_MODES} — the client would " + f"silently render the room VISIBLE") + if keep is not None and not isinstance(keep, bool): + fails.append(f"{key}: liveRoomKeepBackdrop must be a bool, got {type(keep).__name__}") return fails