diff --git a/Assets/Scripts/umamusume/Gallop/Live/StageEnvMapDiag.cs b/Assets/Scripts/umamusume/Gallop/Live/StageEnvMapDiag.cs
new file mode 100644
index 0000000..abb9505
--- /dev/null
+++ b/Assets/Scripts/umamusume/Gallop/Live/StageEnvMapDiag.cs
@@ -0,0 +1,162 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace Gallop.Live
+{
+ ///
+ /// 舞台地板/高光面「过亮发白」的诊断。
+ ///
+ /// 已从 bundle 查明的部分(ground truth,见 LIVE_AUDIT_2026-08-05.md):
+ ///
+ /// * live10149 上发白的 plane_000 / stage_object_001 / specular_002 三个物件,
+ /// 用的是**同一个** shader:Gallop/3D/Live/Stage/DefaultEnvMapNoAmbient。
+ /// 它是舞台 shader 里唯一带环境反射通道的一族。
+ /// * 该 shader 真实声明的属性只有 8 个:
+ /// _MainTex _MulColor0 _ColorPower _AddColor
+ /// _TripleMaskMap _EnvMap _EnvRate(Range 0..1, 默认 0.5) _EnvBias(Range 0..8, 默认 1)
+ /// * 三个材质的 _EnvRate 分别是 0.75 / 1.0 / 1.0,_EnvBias 是 2 / 2 / 5。
+ /// * 材质里 _EnvMap 是**绑定了**的(指向别的 bundle,不是 null)。
+ ///
+ /// 剩下的唯一未知项是运行时:那些跨 bundle 的贴图引用 UmaViewer 到底解析出来没有。
+ /// 如果 _EnvMap 运行时是 null,Unity 会代入默认贴图(白),再乘上 _EnvRate=1.0
+ /// 就是满强度的白色反射 —— 正好是「地板发白」的样子。
+ ///
+ /// 这个类只负责把那个未知项打出来,不做任何修改。
+ ///
+ /// 可证伪的预测:plane_000 的 _EnvRate 是 0.75,另外两个是 1.0,
+ /// 所以**如果**这个假设成立,plane_000 应该比另外两个略轻一些。
+ /// 若三者一样白,说明白化另有来源,这条假设就该丢掉。
+ ///
+ public static class StageEnvMapDiag
+ {
+ private const string kEnvShader = "Gallop/3D/Live/Stage/DefaultEnvMapNoAmbient";
+
+ public static void Dump(StageController stage)
+ {
+ if (stage == null) return;
+
+ var lines = new List();
+ int total = 0, nullEnv = 0;
+
+ foreach (var r in stage.GetComponentsInChildren(true))
+ {
+ foreach (var mat in r.sharedMaterials)
+ {
+ if (mat == null || mat.shader == null) continue;
+ if (mat.shader.name != kEnvShader) continue;
+
+ total++;
+ // 必须问当前 shader;材质的 m_SavedProperties 里还留着一堆 URP Lit 的
+ // 历史属性(_WorkflowMode/_Smoothness/_ClearCoatMask…),那个表不可信。
+ Texture env = mat.HasProperty("_EnvMap") ? mat.GetTexture("_EnvMap") : null;
+ if (env == null) nullEnv++;
+
+ lines.Add(string.Format(
+ " {0,-22} mat={1,-34} _EnvMap={2,-26} _EnvRate={3:F2} _EnvBias={4:F2}",
+ r.gameObject.name,
+ mat.name.Replace(" (Instance)", ""),
+ env == null ? "*** null(会代入白贴图)***" : env.name,
+ mat.HasProperty("_EnvRate") ? mat.GetFloat("_EnvRate") : -1f,
+ mat.HasProperty("_EnvBias") ? mat.GetFloat("_EnvBias") : -1f));
+ break;
+ }
+ }
+
+ if (total == 0)
+ {
+ Debug.Log($"[EnvMapDiag] 本舞台没有使用 {kEnvShader} 的渲染器");
+ return;
+ }
+
+ Debug.Log($"[EnvMapDiag] {kEnvShader}:{total} 个渲染器,其中 _EnvMap 为 null 的有 {nullEnv} 个\n"
+ + string.Join("\n", lines));
+
+ if (nullEnv > 0)
+ Debug.LogWarning($"[EnvMapDiag] {nullEnv}/{total} 个材质的 _EnvMap 运行时没解析出来 —— " +
+ "Unity 会代入白贴图,_EnvRate 越接近 1 就越白。地板发白的成因基本可以坐实。");
+ else
+ Debug.Log("[EnvMapDiag] _EnvMap 全部解析成功 —— 发白不是贴图缺失造成的。" +
+ "按 F9 可在「原始 _EnvRate ↔ 0」之间来回切换,判断反射项是不是发白的来源。");
+
+ if (stage.GetComponent() == null)
+ stage.gameObject.AddComponent();
+ }
+ }
+
+ ///
+ /// 判决实验:按 F9 把 DefaultEnvMapNoAmbient 的 `_EnvRate` 在「原始值 ↔ 0」之间切换。
+ ///
+ /// 为什么需要它:`_EnvRate` 0.75 与 1.00 只差 25%,如果反射项本来就超过 1.0 被钳到白,
+ /// 两者会一样白 —— 所以「plane_000 看起来没比别的轻」**不能**否定反射项假设,
+ /// 那个对比检验设计得太弱。把 `_EnvRate` 直接归零才是二元的:
+ ///
+ /// 发白消失 → 就是 env 反射项,问题转化为「反射强度该由谁压下来」
+ /// 发白照旧 → env 路径无关,改查 Built-in shader 在 URP 下的翻译
+ ///
+ /// 走 MaterialPropertyBlock,不动材质本身;再按一次即可还原。纯诊断,默认不改变任何东西。
+ ///
+ public class StageEnvRateToggle : MonoBehaviour
+ {
+ public KeyCode key = KeyCode.F9;
+
+ private const string kEnvShader = "Gallop/3D/Live/Stage/DefaultEnvMapNoAmbient";
+ private bool _zeroed;
+ private MaterialPropertyBlock _block;
+ private readonly List _targets = new List();
+ private bool _collected;
+
+ private void Collect()
+ {
+ _collected = true;
+ foreach (var r in GetComponentsInChildren(true))
+ {
+ foreach (var mat in r.sharedMaterials)
+ {
+ if (mat != null && mat.shader != null && mat.shader.name == kEnvShader)
+ {
+ _targets.Add(r);
+ break;
+ }
+ }
+ }
+ }
+
+ private void Update()
+ {
+ if (!Input.GetKeyDown(key)) return;
+ if (!_collected) Collect();
+ if (_targets.Count == 0)
+ {
+ Debug.Log("[EnvMapDiag] 本舞台没有带 env 反射的渲染器,F9 无事可做");
+ return;
+ }
+
+ _zeroed = !_zeroed;
+ _block ??= new MaterialPropertyBlock();
+
+ foreach (var r in _targets)
+ {
+ if (r == null) continue;
+ r.GetPropertyBlock(_block);
+ if (_zeroed)
+ {
+ _block.SetFloat("_EnvRate", 0f);
+ }
+ else
+ {
+ // 还原成材质自己的授权值(plane_000=0.75,另两个=1.0)
+ float authored = 0.5f;
+ foreach (var mat in r.sharedMaterials)
+ {
+ if (mat != null && mat.HasProperty("_EnvRate")) { authored = mat.GetFloat("_EnvRate"); break; }
+ }
+ _block.SetFloat("_EnvRate", authored);
+ }
+ r.SetPropertyBlock(_block);
+ }
+
+ Debug.Log($"[EnvMapDiag] _EnvRate → {(_zeroed ? "0(反射关闭)" : "原始值(反射恢复)")}," +
+ $"作用于 {_targets.Count} 个渲染器。地板此刻变了吗?");
+ }
+ }
+}
diff --git a/Assets/Scripts/umamusume/Gallop/Live/StageEnvMapDiag.cs.meta b/Assets/Scripts/umamusume/Gallop/Live/StageEnvMapDiag.cs.meta
new file mode 100644
index 0000000..8d83dcf
--- /dev/null
+++ b/Assets/Scripts/umamusume/Gallop/Live/StageEnvMapDiag.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 2f47145aad184ad3a6ae81cb1b711182
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/LIVE_AUDIT_2026-08-05.md b/LIVE_AUDIT_2026-08-05.md
new file mode 100644
index 0000000..7748c3d
--- /dev/null
+++ b/LIVE_AUDIT_2026-08-05.md
@@ -0,0 +1,535 @@
+# ✅ 轨道审计报告 — 2026-08-05
+
+对 `LIVE_DEV_MAP.md` 中标记为 ✅「已完成」的轨道做实现审计。
+所有结论都用 `tools/` 从 bundle 里取了 ground truth,未验证的猜测不写进来。
+
+结论:**4 条标 ✅ 的轨道实际上不工作或工作错误**,另有一组性能问题。
+它们全部属于同一个已知家族——数据解析正确,然后在下游被静默丢弃,无任何报错
+(和 commit `a3a7b83` 修的六条是同一形状)。
+
+## 当前状态
+
+| # | 问题 | 状态 |
+|---|------|------|
+| A1 | GlobalLight 被 BgColor1 每帧清空(59/59 首) | ✅ 已修 |
+| A2 | BgColor2 丢弃组名(15 首 / 14346 keys) | ⚠️ 部分修复 —— 组名已接通、性能已修;**组名到底指向什么仍无 ground truth**,解析不到时退回原全舞台语义并打警告 |
+| A3 | Spotlight3d 写 `_Color` 是空操作(31 首) | ✅ 已修 |
+| A4 | Transform 只认 unit 名(12 首 / 681 keys) | ✅ 已修 |
+| A5 | UVScrollLight 写 `_Color` 是空操作(**同一 bug 第三例**) | ✅ 已修。目标材质实测是 `LightAdd1_UV` / `StageLightAdd1_UVAlphaMask_TransmittedLightMask`,通道为 `_MulColor0`/`_MulColor1`/`_ColorPower`;顺带补上一直没用的 `mulColor1`,并改 MPB + 缓存(原先每帧遍历全舞台 + `r.materials`)|
+| B1–B4 | 每帧 `r.materials` / 全场景遍历 / 每帧 new MPB | ✅ 随 A1–A3 一并修掉(Wash/Laser 的 `r.materials` 仍在,属 ⚠️ 轨道,未动) |
+| C1 | GlobalFog 把高度雾当距离雾 | ❌ 未动,需先做语料统计定方案 |
+| C2 | 初始化时同名子物件只有第一个被关,且没人再打开 | ✅ 已修(**前两稿的诊断都是错的**,见下文) |
+| C4 | 舞台地板 / 高光面发白 | 🔍 已定位到 `DefaultEnvMapNoAmbient` 的 `_EnvMap`/`_EnvRate`;只剩「运行时贴图有没有解析出来」一个未知项,已加 `StageEnvMapDiag` 打印 |
+| C3 | ParticleGroup 覆盖 Particle | ❌ 未动,优先级低 |
+
+**均未经实机验证** —— 环境里没有 Unity,也没有 C# 编译器,只做了括号配平和符号引用检查。
+下面每条的证据和改法都写在原处。
+
+### 哪首歌能验哪条
+
+本地目前只下载了 **1177** 的 live 数据。1177 里有 BgColor1(20 组) / BlinkLight / Object /
+Effect / GlobalLight / GlobalFog / PostFilm,但**下面三条轨道在 1177 里是空的**,
+改了也看不到任何效果:
+
+| 修复 | 1177 能验? | 需要的歌(按 keyframe 数排序) |
+|------|------------|------------------------------|
+| A1 GlobalLight | ✅ **能** | 1177 有 2 组 / 125+24 keys |
+| A2 BgColor2 | ❌ 1177 无数据 | son1006 (4338) · son1009 (3446) · son1007 (3308) |
+| A3 Spotlight3d | ❌ 1177 无数据 | son1081 (122) · son1048 (103) · son1051 (95) |
+| A4 Transform | ❌ 1177 无数据 | son1028 (206) · son1009 (131) · son1027 (129) |
+
+A1 在 1177 上的判据见下方「A1」条目末尾。注意 1177 的 rim **颜色和宽度几乎不变**
+(`rimColor` RGB 125 帧只有 8 个值且基本同色,`rimFeather`/`rimSpecRate` 全程恒定),
+真正在动的是 `rimColor.a`(0.392→1.0)和 `lightDir`(125 帧 102 个不同值)。
+所以「看不出在动」是正常的,要盯**亮边在剪影上的位置**而不是颜色。
+
+查某首歌带哪些轨道:`~/.venvs/umatools/bin/python3 -c "import csv; ..."` 读
+`out/scan_keys.csv` 对应列即可(该 CSV 是全 59 首的宽表,一行一首)。
+
+---
+
+## A. 功能性 Bug(标 ✅ 但实际不工作)
+
+### A1. GlobalLight (48) 被 BgColor1 每帧清空 —— 全语料 59/59 首受影响
+
+**证据**
+
+```
+LiveTimelineControl.cs:364 AlterUpdate_GlobalLight(...)
+LiveTimelineControl.cs:365 AlterUpdate_BgColor1(...) ← 紧接着跑
+```
+
+两个 handler 都对**同一批** `container.Renderers` 做整块替换:
+
+```csharp
+// Director.cs:347 OnGlobalLightUpdate
+MaterialPropertyBlock propertyBlock = new MaterialPropertyBlock();
+propertyBlock.SetFloat("_RimShadowRate", ...); // 共 13 个 rim 属性
+foreach (var renderer in container.Renderers)
+ renderer.SetPropertyBlock(propertyBlock);
+
+// Director.cs:413 OnBgColor1Update(角色分支)
+MaterialPropertyBlock propertyBlock = new MaterialPropertyBlock();
+propertyBlock.SetColor("_CharaColor", ...); // 只有 5 个属性
+foreach (var renderer in container.Renderers)
+ renderer.SetPropertyBlock(propertyBlock); // ← 整块替换,不是合并
+```
+
+`Renderer.SetPropertyBlock()` 是**替换**语义,不是合并。BgColor1 在 GlobalLight
+之后跑,于是每一帧 GlobalLight 刚写进去的 13 个 rim 属性立刻被抹掉,回落到材质默认值。
+
+歌曲 1177 实测同时存在两者:
+
+```
+bgColor1 groups (20): ['BgBL', 'CharaColor', 'CharaColor', 'Shadow', ...]
+globalLight groups: ['GlobalLight', 'GlobalLight']
+```
+
+覆盖率:`globalLightDataLists` 9636 keys / 59 首,`bgColor1List` 43617 keys / 59 首。
+两条轨道在**每一首歌**里都同时有数据,所以这个 bug 是全语料生效的。
+
+**方案** —— 改成读-改-写,并复用一个常驻 block(顺带干掉每帧 new):
+
+```csharp
+private MaterialPropertyBlock _charaBlock;
+
+// 两个 handler 共用
+private void WriteCharaBlock(Renderer r, Action fill)
+{
+ _charaBlock ??= new MaterialPropertyBlock();
+ r.GetPropertyBlock(_charaBlock); // 先取回渲染器上已有的块
+ fill(_charaBlock);
+ r.SetPropertyBlock(_charaBlock);
+}
+```
+
+`GetPropertyBlock` 会用渲染器当前的块内容覆盖填充传入的 block,所以两条轨道各写各的
+属性、互不清除。
+
+---
+
+### A2. BgColor2 (9) 完全忽略组名,10 个组互相覆盖 —— 15 首 / 14346 keys
+
+**证据** —— 歌曲 1006 的组名(4338 keys,语料内最多):
+
+```
+bgColor2List -> ['BgWashA','BgWashB','BgWashC','BgWashD','BgWashE',
+ 'BgWashF','BgWashG','BgWashH','BgWashI','BgWashJ']
+```
+
+10 个独立的 wash 分区。但:
+
+```csharp
+// LiveTimelineControl.cs:1879 —— TimelineNameHash 字段存在,dispatcher 从不填
+BgColor2UpdateInfo updateInfo = default;
+updateInfo.color1 = ...; updateInfo.color2 = ...; updateInfo.value = ...;
+
+// Director.cs:664 —— handler 也从不读名字,直接刷整个舞台
+foreach (var r in _stageController.GetComponentsInChildren())
+ foreach (var mat in r.materials)
+ if (mat.HasProperty("_AmbientColor"))
+ mat.SetColor("_AmbientColor", c);
+```
+
+后果是双重的:每帧 10 个组按顺序把**整个舞台**刷一遍,**只有最后一个 `BgWashJ` 留下来**,
+而且它会把本该只属于 J 区的颜色涂到所有分区上。前 9 个组的 3900 多个 key 等于不存在。
+
+这和 `a3a7b83` 修掉的 BgColor1 `validBgColorNames` 白名单是同一类错误——组名路由缺失。
+
+**已做**
+
+1. `BgColor2UpdateInfo` 补了 `TimelineName`,dispatcher 从 `sheet.bgColor2List[i].name` 填。
+ (组名一直是可读的 —— 它继承自 `ILiveTimelineGroupDataWithName` 基类,只是从没被传下去。)
+2. handler 改走 `ResolveStageTargets(key, kStageBgColor2Props)`,通道 `_AmbientColor`,
+ 首帧解析一次并缓存,写入用 `MaterialPropertyBlock`。B1/B2 一并解决。
+
+**没做,也不该猜:组名到底指向什么**
+
+全语料 15 首里出现过的组名只有这些:
+
+```
+BgWashA..BgWashO LaserA/B/C BgColor2
+```
+
+已经排除了两种可能:
+
+- **不是 GameObject 名** —— 舞台层级里没有同名对象。
+- **不是 unit 名** —— 已下载的三个舞台(live10132 / 10149 / 10151)的
+ `_stageObjectUnits` 分别是 `blinklight_washlight_wall_a_vertical_000_set`、
+ `neonsign`、`stage000_shop` 这类具体名字,没有 `BgWash*`。
+
+也查过 BlinkLight 的键里有没有回指 BgColor2 的索引字段 —— 没有
+(有 `CmnColorType0Array`/`CmnColorType1Array` 两个 int 数组,语义未知,可能相关但无证据)。
+
+所以它和 BgColor1 的 `BgBL`/`FollowSpotColor`/`Shadow` 属于同一类「非物件名的组」,
+映射未知。当前实现:**名字能解析到渲染器就只写那些;解析不到就退回原来的全舞台写入
+并打一次警告**。行为不会比改动前更差,映射查明后删掉 fallback 分支即可。
+
+---
+
+### A3. Spotlight3d (68) 的颜色写入是空操作 —— 31 首 / 1089 keys
+
+```csharp
+// Director.cs:705
+mat.SetColor("_Color", keyData.color); // ← 没有任何舞台 shader 有 _Color
+mat.SetFloat("_ColorPower", keyData.colorPower); // ← 这个是有的,会生效
+```
+
+**证据** —— 枚举 `shader` bundle 里全部 133 个 `Gallop/3D/{Live,Bg,Stage}` shader,
+暴露 `_Color` 的只有两个,都和聚光灯无关:
+
+```
+Gallop/3D/Bg/BgShadowOnly
+Gallop/3D/Bg/RedAlphaGreenColorShadowFogUVScroll
+```
+
+`Gallop/3D/Live/Stage/*` 里**一个都没有**。灯柱用的是 `StageBeamLight` 系列:
+
+```
+Gallop/3D/Live/Stage/StageBeamLight ['_MulColor0','_MulColor1','_ColorPowerMultiply','_ColorPower']
+Gallop/3D/Live/Stage/StageBeamLightCutoff (同上)
+Gallop/3D/Live/Stage/StageBeamLightFadeout (同上)
+```
+
+这就是 `a3a7b83` 里 BlinkLight 写 `_Color` 那个 bug 的同一份,只是当时没顺手扫到
+Spotlight3d。因为 `_ColorPower` **确实生效**,聚光灯现在的表现是「材质原色 × 关键帧亮度」——
+亮度会跟着音乐动,颜色永远不对。这正是那种「看着能用但不对」的路由问题。
+
+**方案** —— 照搬 `OnBlinkLightUpdate` 已经写好的 shader 探测逻辑:按
+`_MulColor0` → `_MulColor1` → `_BlinkLightColor` 的顺序问 `Shader.GetPropertyType`,
+写命中的那个,`colorPower` 单独进 `_ColorPower`(**不要折进颜色里**,遵守 CLAUDE.md 的规则)。
+
+顺带确认过 key 结构是对的,不用动数据层:`assetName: "spotlight3d000"`、
+`characterIndex: -1`,C# 的 `LiveTimelineKeySpotlight3dData` 字段顺序与 TypeTree 完全一致。
+
+---
+
+### A4. Transform (31) 只认 unit 名,GameObject 名整条轨道静默失效 —— 12 首 / 681 keys
+
+```csharp
+// StageController.cs:186 UpdateTransform
+if (StageObjectUnitMap.TryGetValue(updateInfo.data.name, out StageObjectUnit objectUnit))
+{ ... }
+// 没有 else —— 组名不是 unit 名就什么都不做,也不报错
+```
+
+**证据** —— 歌曲 1028(transformList 语料最多,206 keys)第一个组名:
+
+```
+name: "light001_glow_001"
+position: (-2.0, 0.5, 0.0) scale: (0.2, 0.2, 0.2)
+```
+
+这是 GameObject 命名风格,不是 `neonsign` 那种 unit 名。
+
+这是 `a3a7b83` 修的 Object 轨道 bug 的**镜像**:那次是 handler 只查 `StageObjectMap`、
+漏了 unit 名;这次是只查 `StageObjectUnitMap`、漏了 GameObject 名。同一个函数对里,
+`UpdateObject` 两种都查,`UpdateTransform` 只查一种。
+
+同一处还有一个次要缺陷:
+
+- `StageObjectMap.TryGetValue(child.name, ...)`(:190)没有 `.Replace("(Clone)","")`,
+ 而 `UpdateObject` 有(:96)——map 里存的是剥掉 `(Clone)` 的名字。
+
+> **更正**:初稿说这里「完全无视 `OffsetType`」,这条是错的。
+> `--tree transformList 1028` 实测 `LiveTimelineKeyTransformData` 只有
+> `position` / `rotate` / `scale` 三个字段,**没有 OffsetType**(和 Object 轨道不同),
+> C# 类和 TypeTree 完全一致。所以绝对写入本来就是对的,不需要 Add 相对语义。
+
+**方案** —— 先查 `StageObjectUnitMap`,miss 再查 `StageObjectMap`(剥 `(Clone)`),
+TRS 抽成一个 `ApplyTransformTrs` helper 供两个分支共用。
+
+---
+
+## A5 补记:`_Color` 空写的系统性排查
+
+同一个 bug 出现三次(BlinkLight → Spotlight3d → UVScrollLight)之后,做了一次全量对照,
+以后加新的属性写入可以照跑一遍当回归检查:
+
+```bash
+# 1. 抽出 Director 里写过的所有 shader 属性名
+grep -oE 'Set(Color|Float|Vector|Texture|Int)\("_[A-Za-z0-9_]+"' Director.cs \
+ | sed 's/.*("//;s/"//' | sort -u > /tmp/props.txt
+
+# 2. 拿 499 个 shader 的属性表逐个核对(见本文件末尾的 MetaDb 用法)
+# 重点不是「这个属性存不存在」,而是「它存在于**目标那一族** shader 上吗」
+```
+
+2026-08-05 的结果(23 个属性名):
+
+| 属性 | 拥有它的 shader | 判断 |
+|---|---|---|
+| `_Color` | 152 个,**舞台/Bg 只有 2 个**(`BgShadowOnly`、`RedAlphaGreenColorShadowFogUVScroll`,都与灯光无关)| ❌ 就是这条暴露了 UVScrollLight |
+| `_ProjectorColorPower` | 7 个,其中 6 个是舞台 | ✅ WashLight 的写入是对的 |
+| rim / toon 那批(`_RimColor`…`_CharaColor`)| 100+ 个角色 shader,舞台 0 个 | ✅ 符合预期 |
+
+**教训**:「这个属性存在」不等于「目标 shader 上有这个属性」。
+三次都是栽在这一步 —— 属性名在别的 shader 家族里是真的,于是看起来很合理。
+
+---
+
+## B. 性能 / 一致性(不改变画面,但每帧都在烧)
+
+| # | 位置 | 问题 |
+|---|------|------|
+| B1 | `Director.cs` 668-669 / 702-703 / 719 / 365 | `Renderer.materials` 每帧调用。这个 getter **每次都实例化材质副本并新分配数组**,彻底破坏批处理。`a3a7b83` 已经为 BlinkLight 修过(改 MPB),BgColor2 / Spotlight3d / UVScrollLight / GlobalLight 四处漏网。BgColor2 最严重:最多 15 个组 × 全舞台渲染器 = 每帧上千次材质实例化 |
+| B2 | 同上 + `OnParticleUpdate` / `OnParticleGroupUpdate` | `GetComponentsInChildren()` 每帧全场景遍历 + 分配。应照 `_bgColor1StageCache` 的做法首帧解析一次 |
+| B3 | `Director.cs` 668 / 977 / 988 | 漏了 `true` 参数 → **跳过 inactive 对象**。而 `IsTimelineControlledLight` 命中的灯全部以 inactive 起步,UVScrollLight(:717) 和 Spotlight3d(:702) 传了 `true`,这几处没传。同一份代码里两种写法,是不一致而不是有意设计 |
+| B4 | `Director.cs` 347 / 413 | 每个 locator 每帧 `new MaterialPropertyBlock()`。A1 的修法顺带解决 |
+
+---
+
+## C. 语义存疑(有证据但需要更多 ground truth 才能定方案)
+
+### C1. GlobalFog (49):把高度雾当成距离雾在做
+
+歌曲 1177 的 key 实测:
+
+```json
+"isDistance": 0, "isHeight": 1, "height": 15.8, "heightDensity": 0.03,
+"fogMode": 2, "expDensity": 0.02, "start": 0.0, "end": 300.0
+```
+
+即「**不要**距离雾,要一层 y=15.8 的高度雾」。而 handler:
+
+```csharp
+RenderSettings.fog = keyData.isDistance || keyData.isHeight || keyData.fogMode != 0; // → true
+RenderSettings.fogMode = (FogMode)keyData.fogMode; // → Exponential,枚举值没问题
+RenderSettings.fogDensity = keyData.expDensity; // 距离雾密度
+```
+
+于是数据说「没有距离雾」,画面上却出现了距离雾。`height` / `heightDensity` /
+`startDistance` 三个字段完全没用上。
+
+`(FogMode)2` = `Exponential` 是合法的,枚举转换本身**没有**越界问题——问题只在开关条件。
+
+Unity 的 `RenderSettings` 没有高度雾,做不了 1:1。两条路:
+
+- **保守**:`RenderSettings.fog = keyData.isDistance != 0;`,高度雾留 TODO。
+ 代价是 1177 这类只有高度雾的歌会完全没有雾——比现在错误的雾更接近真相,但视觉上更空。
+- **正确**:高度雾需要自写 URP RendererFeature,和 PostFilm 同一档工作量。
+
+建议先跑一遍 41 首有 globalFog 的歌,统计 `isDistance` / `isHeight` 的分布再决定,
+这个统计 `tools/` 一条命令就能出。
+
+### C2. `StageObjectMap` 去重让重名灯的第 2..N 个永远关不掉
+
+```csharp
+// StageController.cs:60
+if (!StageObjectMap.ContainsKey(child.name)) // ← 用未剥 (Clone) 的名字判重
+{
+ if (IsTimelineControlledLight(child.name))
+ child.gameObject.SetActive(false);
+ var tmp_name = child.name.Replace("(Clone)", "");
+ StageObjectMap.Add(tmp_name, child.gameObject); // ← 存剥掉的名字
+}
+```
+
+`SetActive(false)` 在判重的 `if` **里面**,所以同名对象只有第一个被关掉。
+另外判重用 `child.name`、插入用 `tmp_name`,两个键不一致。
+
+> **这条我连着猜错了两次,实测数据两次都把方案推翻了。记在这里当反面教材。**
+>
+> **初稿的说法**:「一组 66 个同名 `wash_truss_a` 只有第一个被关,其余 65 个常亮」。
+> **错。** 那个 66 是观众 `mob_a000` 的数量(BgColor1 的目标),不是灯。
+> live10149 上重名的受控灯只有 4 个名字 / 10 个对象。
+>
+> **第二稿的说法**:「要先给三个灯光 handler 加 name→List 支持,否则移动 SetActive
+> 会让 65 盏灯全灭」。**也错。** 实测 1177 的 28 个 BlinkLight 组名**全部一一对应
+> 一个 GameObject**(27 命中 + 1 个 `wash_discoball` 无对应物件),组名指向的是**容器**,
+> 真正的灯是子物件,`GetComponentsInChildren` 早就覆盖了。所以 name→List 完全没必要。
+
+**真实的 bug,方向和前两稿都相反**
+
+重名的灯全是容器的**子孙**:
+
+```
+light001_wash_ground_a < swing_wash_ground_a_003 < wash_ground_a_000_l_003_set
+ < pfb_env_live10149_blinklight_wash_ground_a ← 轨道寻址的是这个
+```
+
+容器名含 `blinklight` → 被关掉(正确,等 handler 打开)。
+子物件名含 `_wash_` → **只有第一个**被关掉,而 handler 只 `SetActive(true)` 容器,
+那第一个子物件再没有人打开 —— **全程是黑的**。
+
+live10149 上的受害者:`light001_wash_ground_a`(×4)、`swing_wash_ground_a`(×2)、
+`base_rotate_wash_ground_a`(×2)、`light000_wash_ground_a`(×2),各有 1 盏常黑,共 4 盏。
+
+**已修** —— 只关「本分支里最上层」的那盏受控灯:祖先已经是受控灯的就跳过,
+跟着祖先一起显隐。容器照常关,子物件保持 prefab 状态。判重键也统一成 `tmp_name`。
+纯增益,没有「某盏灯从此不亮」的倒退风险 —— 因为不再有子物件被单独关掉。
+
+### C4. 舞台地板 / 高光面发白 —— 已定位到 shader,只剩一个运行时未知项
+
+CLAUDE.md 长期记着「地板 `plane_000` / `stage_object_001` / `specular_002` 偏亮发白,
+早于时间轴的改动就存在」。这次查清了它们的共同点。
+
+**已确认(bundle ground truth)**
+
+三个物件用的是**同一个** shader,而且是舞台 shader 里唯一带环境反射的一族:
+
+```
+Gallop/3D/Live/Stage/DefaultEnvMapNoAmbient
+ 真实属性表(只有 8 个):
+ _MainTex _MulColor0 _ColorPower _AddColor
+ _TripleMaskMap _EnvMap _EnvRate(Range 0..1, 默认 0.5) _EnvBias(Range 0..8, 默认 1)
+```
+
+| 物件 | 材质 | `_EnvRate` | `_EnvBias` |
+|---|---|---|---|
+| `plane_000` | `mtl_env_live10149_default_env001` | **0.75** | 2.0 |
+| `stage_object_001` | `mtl_env_live10149_default_env002` | **1.00** | 2.0 |
+| `specular_002` | `mtl_env_live10149_glitter002` | **1.00** | 5.0 |
+
+三个材质的 `_EnvMap` 在数据里都是**绑定了**的(指向其它 bundle,`m_FileID` 1/2,不是 null)。
+
+顺带印证了 CLAUDE.md 那条警告:这三个材质的 `m_SavedProperties` 里还留着一大堆
+URP Lit 的属性(`_WorkflowMode`、`_Smoothness`、`_ClearCoatMask`、`_BlendModePreserveSpecular`…),
+而当前 shader 一个都没声明。**存档表不可信,必须问 shader。**
+
+**实机日志结果:贴图缺失假设被证伪**
+
+```
+[EnvMapDiag] DefaultEnvMapNoAmbient:3 个渲染器,其中 _EnvMap 为 null 的有 0 个
+ plane_000 mat=mtl_env_live10149_default_env001 _EnvMap=tex_env_live10149_reflection000 _EnvRate=0.75 _EnvBias=2.00
+ specular_002 mat=mtl_env_live10149_glitter002 _EnvMap=tex_env_live10149_reflection001 _EnvRate=1.00 _EnvBias=5.00
+ stage_object_001 mat=mtl_env_live10149_default_env002 _EnvMap=tex_env_live10149_reflection000 _EnvRate=1.00 _EnvBias=2.00
+```
+
+贴图全部解析成功,不是缺贴图。假设作废。
+
+**已排除的三条**
+
+| 排除项 | 依据 |
+|---|---|
+| 光照太强 | 舞台 `Light` 组件 0 个,Live 代码不碰 `Light` |
+| `_EnvMap` 缺失 | 上面的日志,0 个 null |
+| BgColor1 的 `power` 没写进 `_ColorPower` | 三个组的 `power` **中位数都是 1.000**(范围 0.09–1.0,13 个不同值)。就算写了,绝大多数时间也是乘 1,**解释不了持续发白**。这条负面结果反过来支持了当初撤回该写入的决定 |
+
+**候选 A(证据较强):env 反射项不受颜色通道调制**
+
+这是一个干净的对照:
+
+- 发白的 3 个物件 = 全场仅有的 3 个用 `DefaultEnvMapNoAmbient` 的物件。
+- 它们的 `_MulColor0` 被 BgColor1 写成**接近黑**:1177 开场是 `(0.06, 0.06, 0.08)`,
+ 257 个关键帧 44 种颜色,确实在动。
+- 同样由 BgColor1 写同一个 `_MulColor0` 通道、但用 `DefaultNoAmbient`(无 env)的
+ `mob_a000`(50 renderer) / `mob_b000`(57) / `truss_neonsign` 等**没有发白问题**。
+
+同一个写入者、同一个通道,唯一变量是 env 路径 —— 颜色都压到近黑了还是亮,
+说明亮的那一项**不经过 `_MulColor0`**。
+
+~~可证伪预测:`plane_000` 的 `_EnvRate` 是 0.75、另两个是 1.0,所以 `plane_000` 应当
+明显比另外两个轻。如果三者一样白,候选 A 也要丢掉。~~
+
+> **这个检验设计得不好,实测「看不出差异」并不能否定候选 A。**
+> 0.75 与 1.00 只差 25%,如果反射项本来就超过 1.0 被钳到白,两者会一样白。
+> 也就是说这个对比无论结果如何都区分不了 —— 它不是一个有效的可证伪检验,是我把它说过头了。
+>
+> 真正二元的检验是把 `_EnvRate` **直接归零**:
+> 发白消失 → 就是 env 反射项;发白照旧 → env 路径无关。
+> 已实现为 `StageEnvRateToggle`(按 **F9** 在「原始值 ↔ 0」间来回切,走 MPB 不动材质,
+> 随 `StageEnvMapDiag.Dump()` 自动挂上)。
+
+**候选 B(可能相关,未证实):缺失的平面镜面反射系统**
+
+Environment (58) 轨道(1375 keys / 49 首,MAP 记为「Planar Reflection 完全没有」)在 1177 带着:
+
+```
+isValidMirror: 1 isBgMirror: 1 mirrorReflectionRate: 0.134
+```
+
+反射强度 0.134,而材质里是 0.75–1.0。**但 `mirrorReflectionRate` → `_EnvRate` 没有任何证据**:
+前者属于平面镜系统(第二摄像机 → RenderTexture),后者是静态 env 贴图的混合率,
+是两套东西。Environment 的 key 里也没有任何直接对应 env map 的字段(其余全是 water/fov)。
+所以这条只当线索,不当结论。
+
+**判决实验结果:候选 A、B 均被否定**
+
+F9 实测。日志确认开关确实生效(9 次切换 × 3 个渲染器,`[EnvMapDiag] _EnvRate → 0/原始值`),
+**画面无变化**。所以 env 反射项不是发白的来源,候选 A 作废;候选 B 依附于同一表面的
+反射设想,一并降级。
+
+**同时发现:地板本来就该是亮的**
+
+之前只看了第 0 帧的 `(0.06, 0.06, 0.08)` 就默认"数据要求地板是暗的",这是取样偏差。
+全曲 257 个关键帧的亮度分布(Rec.601 luma):
+
+| | plane_000 |
+|---|---|
+| 中位数 | **0.548** |
+| 均值 | 0.505 |
+| > 0.3 | 207 / 257(81%)|
+| > 0.5 | 133 / 257(52%)|
+| > 0.8 | 36 / 257(14%,最高 0.95)|
+
+近黑只出现在开场几帧。**全曲一多半时间地板本来就该是中等偏亮的** ——
+所以"偏亮"这个主观印象里有多少是 bug、多少是原本的美术意图,目前无法区分。
+
+**已排除的机制汇总(4 条)**
+
+1. 光照太强 —— 舞台 `Light` 组件 0 个
+2. `_EnvMap` 缺失 —— 日志实测 0 个 null
+3. BgColor1 `power` → `_ColorPower` —— 中位数 1.000,写了也是乘 1
+4. env 反射项 —— `_EnvRate` 归零无变化
+
+**下一个二元实验(未做)**:把 `_MulColor0` 强制设为纯黑。
+地板变黑 → BgColor1 的写入确实主导表面,亮度就是数据本身,"发白"是美术预期问题;
+地板仍白 → 写入没有到达表面,回头查 MPB / renderer 解析 / shader 是否真的用这个通道。
+
+### C3. ParticleGroup 覆盖 Particle(6 首 / 31+27 keys,优先级低)
+
+两个 handler 都按 `ps.gameObject.name == data.name` 匹配并写同一个
+`emission.rateOverTime`,ParticleGroup 在后(:376 vs :375),同名时覆盖 Particle。
+另外 `new MinMaxCurve(FlickerDarkRate, FlickerLightRate)` 隐含 dark < light,
+数据里是否成立没验证过。数据量很小,可以最后处理。
+
+---
+
+## 建议顺序
+
+按「影响面 × 修复成本」排:
+
+1. **A1** GlobalLight 被清空 —— 59/59 首,改动约 15 行,收益最大
+2. **A2** BgColor2 组名路由 —— 15 首 / 14346 keys,可复用 BgColor1 现成的解析缓存,顺带清掉 B1/B2 最重的一处
+3. **A3** Spotlight3d `_Color` —— 31 首,照抄 BlinkLight 的探测逻辑
+4. **C2** StageObjectMap 去重 —— 4 行,且会影响 A2/A3 的解析结果,建议和它们一起做
+5. **A4** Transform 解析 —— 12 首,需要先 dump 确认 OffsetType 是否存在
+6. **B1–B4** 性能 —— 多数在 1–4 做完时自然消掉,剩余单独扫一遍
+7. **C1 / C3** —— 需要先做语料统计才能定方案
+
+前 4 项之后建议重跑 `LiveTimelineWorksheetDiag.Dump()` 对照歌曲 1177 与 1006,
+确认组名解析命中率,再更新 `LIVE_DEV_MAP.md` 里这几行的 ✅/⚠️ 标记。
+
+---
+
+## 附:本次用到的验证命令
+
+```bash
+cd tools
+P=~/.venvs/umatools/bin/python3
+
+# 轨道覆盖率(keys / 首数)
+$P -c "import csv;rows=list(csv.DictReader(open('out/scan_keys.csv')));
+ v=[int(r['bgColor2List']) for r in rows];print(sum(v), sum(1 for x in v if x>0))"
+
+# 组名 —— 判断轨道是按 GameObject 名还是 unit 名 / 分区名路由
+$P -c "from uma_common import MetaDb
+env=MetaDb().load('cutt/cutt_son1006/son1006_camera')
+for o in env.objects:
+ if o.type.name=='MonoBehaviour':
+ t=o.read_typetree()
+ if t.get('bgColor2List'): print([g['name'] for g in t['bgColor2List']])"
+
+# shader 属性表 —— 确认某个属性名到底存不存在
+$P -c "from uma_common import MetaDb
+for o in MetaDb().load('shader').objects:
+ if o.type.name!='Shader': continue
+ pf=o.read_typetree()['m_ParsedForm']
+ if pf['m_Name'].startswith('Gallop/3D/Live/Stage'):
+ print(pf['m_Name'], [p['m_Name'] for p in pf['m_PropInfo']['m_Props']])"
+
+# 单个 key 的真实值
+$P dump_cutt_typetree.py --sample spotlight3dList 1081
+$P dump_cutt_typetree.py --tree transformList 1028
+```
diff --git a/LIVE_DEV_MAP.md b/LIVE_DEV_MAP.md
index d4923a0..5993dd7 100644
--- a/LIVE_DEV_MAP.md
+++ b/LIVE_DEV_MAP.md
@@ -21,45 +21,65 @@ Toon shader 已移植,卡住的主要是全屏后处理和几个逆向未完
---
+## 状态记号
+
+2026-08-05 起区分「实现存在」和「实现被核查过」:
+
+| 记号 | 含义 |
+|------|------|
+| ✅ | 已实现,**且本次审计核查过**(读代码 + 用 `tools/` 对过 bundle 数据)|
+| ✅? | 标着已实现,但**没人核查过**。本项目已经出现过 8 次「看着能用其实整条静默失效」,未核查 ≠ 正常 |
+| ⚠️ | 已实现但不完整,缺的部分写在备注里 |
+| ❌ | 未实现 |
+
+审计报告见 `LIVE_AUDIT_2026-08-05.md`。
+
+---
+
## 轨道 → Unity API 对照
### GameObject / Transform
| 轨道 | 状态 | 操作 |
|------|------|------|
-| Object (33) | ✅ | `SetActive` |
-| Transform (31) | ✅ | position / rotation / scale |
+| Object (33) | ✅? | `SetActive` |
+| Transform (31) | ✅ | position / rotation / scale。2026-08-05 修:原先只查 `StageObjectUnitMap`,组名是 GameObject 名(son1028 的 `light001_glow_001`)时整条轨道静默失效,12 首 / 681 keys 从未生效 |
| WashLight (43) | ⚠️ | `SetActive`(颜色/投影未做) |
| Laser (44) | ⚠️ | `SetActive` + transform(blink 未做) |
-| BlinkLight (45) | ⚠️ | `SetActive` + 子 `Light`(color1Array/BlendMode 未做) |
-| Spotlight3d (68) | ✅ | `SetActive` + `Light.color/intensity` |
-| Effect (60) | ✅ | `Instantiate` prefab |
+| BlinkLight (45) | ⚠️ | `SetActive` + 写 Renderer 的 `_BlinkLightColor`/`_ColorPower`(color1Array/BlendMode 未做) |
+| Spotlight3d (68) | ✅ | `SetActive` + 写 Renderer 颜色属性(**不是** Unity `Light`)。2026-08-05 修:原先写 `_Color`,而 `Gallop/3D/Live/Stage/*` 下**没有任何 shader** 声明该属性(133 个全枚举过),颜色写入一直是空操作;灯柱是 `StageBeamLight` 系列 → `_MulColor0` + `_ColorPower`,改用 BlinkLight 的 shader 探测 |
+| Effect (60) | ✅? | `Instantiate` prefab |
+
+> ⚠️ **舞台上没有 Unity `Light` 组件。** live10149 实测:10102 个 GameObject、
+> 898 MeshRenderer、602 SkinnedMeshRenderer、**Light 0 个**;整个 Live 代码也没有任何
+> 地方读写 `Light.color/intensity`。所有「灯」都是自发光几何体,靠时间轴写 shader 颜色属性。
+> 本表此前把 BlinkLight / Spotlight3d 写成「子 Light color/intensity」,是错的,已更正。
### Camera
| 轨道 | 状态 |
|------|------|
-| CameraPos (1) / LookAt (2) / Fov (3) / Roll (4) | ✅ |
-| CameraSwitcher (11) | ✅ |
-| MultiCameraPos (53) / LookAt (54) | ✅ |
+| CameraPos (1) / LookAt (2) / Fov (3) / Roll (4) | ✅? |
+| CameraSwitcher (11) | ✅? |
+| MultiCameraPos (53) / LookAt (54) | ✅? |
### MaterialPropertyBlock → Shader
| 轨道 | 状态 | 属性 |
|------|------|------|
| BgColor1 (8) 角色分支 | ⚠️ | `_CharaColor/_ToonDarkColor/_ToonBrightColor/_OutlineColor/_Saturation`;`vertexColorToonPower`/`outlineWidthPower`/`LightBlendMode`/`IsSilhouette` 已填入 UpdateInfo 但 handler 还没用 |
| BgColor1 (8) 舞台分支 | ⚠️ | 2026-08-04 新增。遍历舞台层级按 Transform 名解析 Renderer,写 **`_MulColor0`**(已确认)。`_AmbientColor` 归 BgColor2,别抢 |
-| GlobalLight (48) | ✅ | 角色 rim light 属性 |
+| GlobalLight (48) | ✅ | 角色 rim light 属性。2026-08-05 修:`SetPropertyBlock` 是整块替换,BgColor1 角色分支在它之后跑,每帧把 13 个 rim 属性全抹掉 —— 全语料 59/59 首同时有这两条轨道,等于从来没生效过。两处都改成 `GetPropertyBlock` 读-改-写 |
| UVScrollLight (46) | ⚠️ | `_MainTex` offset 累积(已修 bug,mulColor 等未用) |
### ParticleSystem
| 轨道 | 状态 |
|------|------|
-| Particle (41) | ✅ `emission.rateOverTime` |
-| ParticleGroup (42) | ✅ `FlickerLightRate` MinMaxCurve |
+| Particle (41) | ✅? `emission.rateOverTime`;每帧全场景 GetComponentsInChildren 且漏 inactive |
+| ParticleGroup (42) | ✅? `FlickerLightRate` MinMaxCurve;与 Particle 同名时会覆盖它 |
### RenderSettings
| 轨道 | 状态 |
|------|------|
-| GlobalFog (49) | ✅ fogColor / fogMode / fogDensity / fogStart / fogEnd |
-| BgColor2 (9) | ✅ 写含 `_AmbientColor` 属性的舞台材质,`Lerp(color1, white, value)` |
+| GlobalFog (49) | ⚠️ 已核查:数据是**高度雾**(`isDistance=0,isHeight=1`),却当成距离雾在开;Unity RenderSettings 没有高度雾。见审计报告 C1 |
+| BgColor2 (9) | ⚠️ 写 `_AmbientColor`,`Lerp(color1, color2, value)`。2026-08-05:组名已接通(原先整条被丢弃,最多 15 个组每帧各刷一遍全舞台、只有最后一个留得下来),性能已修。**但 `BgWashA..O`/`LaserA..C` 既不是 GameObject 名也不是 unit 名,映射未知** —— 解析不到时退回全舞台写入并打警告 |
### URP Volume / RendererFeature
| 轨道 | 状态 |
@@ -72,8 +92,8 @@ Toon shader 已移植,卡住的主要是全屏后处理和几个逆向未完
### 内部系统
| 轨道 | 状态 |
|------|------|
-| CharaMotionSequence (7) / LipSync (12) / FormationOffset (28) | ✅ |
-| FacialFace/Eye/Mouth 等 (18–25) | ✅ |
+| CharaMotionSequence (7) / LipSync (12) / FormationOffset (28) | ✅? |
+| FacialFace/Eye/Mouth 等 (18–25) | ✅? |
---
@@ -141,3 +161,4 @@ Toon shader 已移植,卡住的主要是全屏后处理和几个逆向未完
| `Assets/Resources/RenderPipeline/UMAUniversalRenderPipelineAsset_Renderer.asset` | RendererFeature 注册位置 |
| `LIVE_TRACKS.md` | 103 条轨道全览(ID/覆盖率/状态/Bug)|
| `live-shader-todo.md` | 后处理轨道实现细节 |
+| `LIVE_AUDIT_2026-08-05.md` | ✅ 轨道审计报告:A1–A4 已修的证据与改法,C1–C3 未修项及其阻塞原因 |