Live: rebind the stage's own scripts; fix mirror ball colour; map what is recoverable

The big finding is structural, and it corrects something I asserted two commits ago.

Stage prefabs reference 11 script classes and UmaViewer implemented one of them, leaving
892 MonoBehaviour instances with a null script. I claimed those serialized fields were
unreachable at runtime and that reimplementing the scripts would need name heuristics or a
baked parameter table. That was wrong, and the diagnostic I wrote to confirm it disproved it
instead: among the 7 surviving components were AssetHolder (bundle namespace Gallop) and
StageController (Gallop.Live). Unity resolves MonoScript by class name + namespace +
assembly, and Assets/Scripts/umamusume.asmdef is named "umamusume", exactly matching every
MonoScript in the bundles. The project was organised for this from the start — it is the same
mechanism LiveTimelineWorkSheet relies on to read cutt data.

So the 892 are simply unwritten. Declaring a class with the matching signature makes Unity
deserialize the real fields.

BillboardController (Gallop.Live, 164 instances) is the first one rebuilt, and it confirms
the mechanism: missing-script count drops 892 -> 728, exactly -164. _rotationType is now read
per object rather than guessed. Type 0 (106 objects) faces the camera; type 2 (58, all
glow_ramp) is logged and left alone — the enum's meaning is not recoverable, see below.

BlinkLight palette slots — the mirror ball was solid pink
  * color0Array/powerArray are a 10-slot palette and every renderer was taking slot 0.
    The slot is the object's own or nearest ancestor's `lightNNN_` / `alphaNNN_` name prefix.
    Confirmed across all groups on live10149: the number of distinct prefixes equals the
    number of distinct colours in slots 0..N-1, with slots N..9 uniform white filler, in
    every group without exception. mirrorball 3 (pink/blue/yellow), wash_truss 4, glow_object
    2, audience_light 9.
  * Renderers with no prefix are not palette lights and are now skipped when the group has
    any. The mirror ball body uses Gallop/3D/Bg/BgMirrorBall, whose _MulColor0 belongs to
    BgColor1 — which drives it to white, the silver ball seen in the reference footage.
    BlinkLight runs after BgColor1 and was overwriting it with palette pink.

Stage animation is now driven by the timeline, not Unity's clock. Using Animation.Play() plus
a default clip with playAutomatically meant the stage kept moving while the live was paused
and ignored scrubbing. AnimationState.time is now set from currentLiveTime and sampled.

CLAUDE.md gains a ground-truth map: what the bundles yield, the rebinding trick above with
its signature table, and what is permanently blocked. global-metadata.dat is neither on disk
nor inside GameAssembly.dll (magic 0xFAB11BAF absent, identifier strings absent) — the game's
anti-tamper keeps it encrypted until runtime. Every enum's meaning, the original method
bodies, and any tuning constants are therefore unrecoverable; rebuilding a signature yields
data, never behaviour. The mirror ball's 2.0s-per-revolution is authored in the clip and
reproduced faithfully; if the reference looks slower, the factor lives in code we cannot read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ritenseki
2026-08-05 14:25:37 +08:00
parent b2c607b7d8
commit 4d7a7c3373
6 changed files with 367 additions and 29 deletions
@@ -0,0 +1,71 @@
using UnityEngine;
namespace Gallop.Live
{
/// <summary>
/// 舞台面片朝向摄像机。对应轨道 Billboard (75),live10149 上 164 个实例。
///
/// 这是第一个「按原签名重建」的舞台脚本。bundle 里的 MonoScript 记着
/// ClassName=BillboardController、Namespace=Gallop.Live、AssemblyName=umamusume,
/// 而 <c>Assets/Scripts/umamusume.asmdef</c> 的程序集名正是 umamusume,
/// 所以只要类名 + namespace 对上,Unity 就会把下面三个字段从 bundle 反序列化进来 ——
/// 不需要按名字猜,也不需要把 dump 的参数表打包进项目。
///
/// 字段名必须和 bundle 的 TypeTree 逐字一致(大小写敏感),实测为:
/// _targetCameraTransform / _rotationType / _isInversedForward
///
/// live10149 的实测取值:`_targetCameraTransform` 164/164 全为空 → 回落主摄像机;
/// `_isInversedForward` 全 0;`_rotationType` 只有 0(106 个,wash/glow 类)
/// 和 2(58 个,全是 glow_ramp)两种。
///
/// ⚠ `_rotationType` 的枚举语义仍未知。0 是枚举零值、类名又是 BillboardController,
/// 按「整体朝向摄像机」处理是合理的;**2 代表什么没有依据,故不处理**,
/// 只打一次日志。等拿到证据再补,不猜。
/// </summary>
public class BillboardController : MonoBehaviour
{
// ↓ 这三个名字来自 bundle TypeTree,改名就收不到数据
[SerializeField] private Transform _targetCameraTransform;
[SerializeField] private int _rotationType;
[SerializeField] private bool _isInversedForward;
private Transform _cachedTarget;
private static bool _loggedUnknownType;
private Transform ResolveTarget()
{
if (_targetCameraTransform != null) return _targetCameraTransform;
// 舞台数据里这个字段全是空的,回落到当前生效的直播摄像机。
if (Director.instance != null && Director.instance.MainCameraTransform != null)
return Director.instance.MainCameraTransform;
var cam = Camera.main;
return cam != null ? cam.transform : null;
}
private void LateUpdate()
{
// 放 LateUpdate:必须在 Director 当帧摆好摄像机之后再朝向它,否则永远差一帧。
if (_rotationType != 0)
{
if (!_loggedUnknownType)
{
_loggedUnknownType = true;
Debug.Log($"[Billboard] _rotationType={_rotationType}(如 '{name}')语义未知,未处理。" +
"live10149 上这一类共 58 个,全是 glow_ramp。");
}
return;
}
if (_cachedTarget == null) _cachedTarget = ResolveTarget();
if (_cachedTarget == null) return;
Vector3 forward = transform.position - _cachedTarget.position;
if (_isInversedForward) forward = -forward;
if (forward.sqrMagnitude < 1e-8f) return;
transform.rotation = Quaternion.LookRotation(forward.normalized, _cachedTarget.up);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4f447e99d9a146aeaed47469b57fa63c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+103 -12
View File
@@ -983,25 +983,42 @@ namespace Gallop.Live
_blinkLightBlock ??= new MaterialPropertyBlock();
int noProp = 0;
// color0Array/powerArray 恒为 10 项,是「调色板」而不是每盏灯一项
// (实测:renderer 数可达 570,数组仍是 10;wash_truss_a 的前 4 项正好等于本曲成员数)。
// 每盏灯到调色板槽位的映射规则尚未逆出来,暂统一取第 0 槽 —— 对灯牌是正确的
// (它 6 个有效槽同色),对多色组(wash/mob)是简化。
Color baseCol = (keyData.color0Array != null && keyData.color0Array.Length > 0)
? keyData.color0Array[0] : Color.white;
float basePower = (keyData.powerArray != null && keyData.powerArray.Length > 0)
? keyData.powerArray[0] : 1f;
// color0Array/powerArray 恒为 10 项,是「调色板」而不是每盏灯一项。
//
// 槽位映射规则(2026-08-05 逆出来的):**槽位号 = 渲染器自身或最近祖先的
// `lightNNN_` 名字前缀**。live10149 全组实测,每一组里「前缀种数 N」与
// 「槽位 0..N-1 的去重颜色数」精确相等,且槽位 N..9 无一例外是白色填充:
//
// mirrorball_flarelight N=3 粉/蓝/黄 槽3..9 全白
// wash_truss_a / _b N=4 黄/绿/蓝/紫 槽4..9 全白
// glow_object N=2 橙/绿 槽2..9 全白
// wash_ground_b N=1 单色 槽1..9 全白
// audience_light N=9 本来就单色 槽9 全白
//
// 此前统一取第 0 槽,于是多色组被涂成单色 —— 迪斯科灯球整个发粉红
// (它的槽 0 是 (1.0,0.53,0.71)),truss wash 也丢掉了黄绿蓝紫四色。
float baseElapsed = _liveTimelineControl.currentLiveTime - keyData.frame / 60f - keyData.waitTime;
float cycle = keyData.turnOnTime + keyData.keepTime + keyData.turnOffTime + keyData.intervalTime;
bool groupHasSlots = GroupHasSlottedRenderers(data.name, renderers);
for (int i = 0; i < renderers.Length; i++)
{
var r = renderers[i];
if (r == null) continue;
Color col = baseCol;
float power = basePower;
BlinkTarget t = ResolveBlinkTarget(r);
// 没有槽位前缀 = 不是调色板灯(镜面球球体、灯具外壳等),别抢别人的通道。
if (t.slot < 0 && groupHasSlots) continue;
int slot = t.slot < 0 ? 0 : t.slot;
Color col = (keyData.color0Array != null && keyData.color0Array.Length > 0)
? keyData.color0Array[Mathf.Clamp(slot, 0, keyData.color0Array.Length - 1)]
: Color.white;
float power = (keyData.powerArray != null && keyData.powerArray.Length > 0)
? keyData.powerArray[Mathf.Clamp(slot, 0, keyData.powerArray.Length - 1)]
: 1f;
if (keyData.pattern != 0)
{
@@ -1017,7 +1034,6 @@ namespace Gallop.Live
// 这些灯的 shader 是 Gallop/3D/Live/Stage/LightBlinkBlend,唯一的 Color 属性是
// _BlinkLightColor。之前写的是 _Color —— 该 shader 上没有这个属性,写入是空操作,
// 于是 _BlinkLightColor 一直是默认值,灯全渲染成黑块。
BlinkTarget t = ResolveBlinkTarget(r);
if (t.colorProp == null) { noProp++; continue; }
r.GetPropertyBlock(_blinkLightBlock);
@@ -1054,6 +1070,80 @@ namespace Gallop.Live
{
public string colorProp;
public bool hasColorPower;
/// <summary>调色板槽位 = 自身或最近祖先的 `lightNNN_` 名字前缀。</summary>
public int slot;
}
/// <summary>
/// 从 "light003_xxx" / "alpha001_xxx" 取出槽位号;不匹配返回 -1。
/// 形状是「小写词 + 数字 + 下划线」。
///
/// 一开始只认 "light" 前缀,结果镜面球 83 个渲染器里 47 个是 "alphaNNN_"(发光贴片),
/// 全落回槽 0,整个球还是粉红的。全舞台实测只存在 light(702) 和 alpha(207) 两种前缀词,
/// 放宽成通配后覆盖率与调色板用量吻合:
/// mirrorball_flarelight 槽0/1/2 各 24 ↔ 3 色
/// glow_ramp 槽0/1/2 20/19/19 ↔ 3 色
/// glow_object 槽0/1 33/34 ↔ 2 色
/// audience_light 槽0..8 各 10 ↔ 9 槽
/// 剩下未命中的都在单槽组里,落回槽 0 本来就是对的。
///
/// 手写而不用 Regex:每个 Renderer 只解析一次并缓存,但会走一遍祖先链,
/// 570 个渲染器的量级没必要再加正则的分配。
/// </summary>
private static int ParseLightSlotPrefix(string name)
{
if (string.IsNullOrEmpty(name)) return -1;
int i = 0;
while (i < name.Length && name[i] >= 'a' && name[i] <= 'z') i++;
if (i == 0) return -1; // 必须以小写词开头
int value = 0, digits = 0;
while (i < name.Length && name[i] >= '0' && name[i] <= '9')
{
value = value * 10 + (name[i] - '0');
i++; digits++;
}
if (digits == 0 || i >= name.Length || name[i] != '_') return -1;
return value;
}
/// <summary>没有前缀返回 -1 —— 那不是调色板灯,见 _blinkGroupHasSlots 的说明。</summary>
private static int ResolveBlinkSlot(Renderer r)
{
for (Transform t = r.transform; t != null; t = t.parent)
{
int s = ParseLightSlotPrefix(t.name);
if (s >= 0) return s;
}
return -1;
}
/// <summary>
/// 某个 BlinkLight 组里是否存在带槽位前缀的渲染器。
///
/// 一个组里混着两种东西。以 mirrorball_flarelight 为例,83 个渲染器里既有
/// **闪光片**(`light000_*` / `alpha001_*`,吃调色板颜色),也有**球体本身**
/// (`mirrorball_b_000`,用 Gallop/3D/Bg/BgMirrorBall)。
///
/// 球体的 `_MulColor0` 是 **BgColor1** 的通道 —— 它有 `mirrorball_b_000..004`
/// 五个组,给球体写的是 (0.21,0.22,0.32) → (1,1,1),也就是原版那个银白色球。
/// 但 BlinkLight 在 AlterLateUpdate 里排在 BgColor1 之后,之前会把调色板的粉色
/// 盖上去,于是球被涂成粉红 —— 和原版截图的银球完全不符。
///
/// 所以:**组里只要有带前缀的渲染器,就只染带前缀的那些**,没前缀的留给它真正的
/// 所有者(BgColor1)。整组都没前缀时(单色组)维持旧行为,全部按槽 0 处理。
/// </summary>
private readonly Dictionary<string, bool> _blinkGroupHasSlots = new Dictionary<string, bool>();
private bool GroupHasSlottedRenderers(string groupName, Renderer[] renderers)
{
if (_blinkGroupHasSlots.TryGetValue(groupName, out bool cached)) return cached;
bool any = false;
foreach (var r in renderers)
{
if (r != null && ResolveBlinkTarget(r).slot >= 0) { any = true; break; }
}
_blinkGroupHasSlots[groupName] = any;
return any;
}
private readonly Dictionary<Renderer, BlinkTarget> _blinkPropCache = new Dictionary<Renderer, BlinkTarget>();
@@ -1064,6 +1154,7 @@ namespace Gallop.Live
if (_blinkPropCache.TryGetValue(r, out BlinkTarget cached)) return cached;
BlinkTarget t = default;
t.slot = ResolveBlinkSlot(r);
foreach (var mat in r.sharedMaterials)
{
if (mat == null) continue;
@@ -54,12 +54,18 @@ namespace Gallop.Live
if (clips.Count == 1)
{
// 唯一解:把它设成默认 clip 并在每次启用时播放。
anim.clip = clips[0];
var auto = anim.gameObject.GetComponent<StageAnimationAutoPlay>();
if (auto == null) auto = anim.gameObject.AddComponent<StageAnimationAutoPlay>();
auto.Bind(anim, clips[0].name);
// 唯一解。注意**不要**设 anim.clip、也不要 Play():
// playAutomatically 是 true,一旦有了默认 clip,Unity 会用自己的时钟
// 自动播放,于是 live 暂停/拖动进度条时舞台照转。必须由时间轴驱动。
anim.Stop();
var driver = anim.gameObject.GetComponent<StageAnimationAutoPlay>();
if (driver == null) driver = anim.gameObject.AddComponent<StageAnimationAutoPlay>();
driver.Bind(anim, clips[0]);
single++;
// 打出时长:镜面球那条 clip 离线量到 2.0s 转满 360°(30 RPM),
// 如果运行时 length 不是 2.0,说明转速问题出在 clip 解析而不是编排。
Debug.Log($"[StageAnim] 接管 '{anim.gameObject.name}' ← {clips[0].name} " +
$"length={clips[0].length:F3}s wrapMode={clips[0].wrapMode}");
continue;
}
@@ -79,29 +85,114 @@ namespace Gallop.Live
}
/// <summary>
/// 在 OnEnable 时播放绑定的 clip。受时间轴控制的舞台灯初始 inactive,
/// 等 BlinkLight/WashLight/Laser 的 handler 把它们 SetActive(true) 之后才需要动起来。
/// 缺失脚本的运行时普查。
///
/// **2026-08-05 更正**:这段最初写着「新建同名 class 绑不上,字段运行时读不到」,
/// 那是错的。这次普查的结果自己推翻了它 —— 存活的 7 个里有 `AssetHolder`
/// (bundle ns=`Gallop`)和 `StageController`(ns=`Gallop.Live`),它们**正是靠
/// 类名 + namespace + 程序集名对上才绑定成功的**。
///
/// `Assets/Scripts/umamusume.asmdef` 的 name 就是 `umamusume`,和 bundle 里
/// 每个 MonoScript 的 `m_AssemblyName` 完全一致 —— 这个项目本来就是为此组织的
/// (`LiveTimelineWorkSheet` 读 cutt 数据靠的也是同一个机制)。
///
/// 所以那 892 个只是**没人写**,不是读不到。按下面的签名建类,Unity 会把
/// `_rotationType`、`MirrorBallLoopRotationSpeed` 这些序列化字段真正填进来:
///
/// Gallop.Live AnimationObjectController / BillboardController /
/// LightProjection / UnityLensFlareController / WashLightController
/// Gallop.RenderPipeline MirrorBallProjector / CustomProjector / CustomLensFlare
/// Gallop MirrorReflection
/// Gallop.Live.ShaderParam ShaderParamController
///
/// 这段普查保留下来当进度表:每实现一个类,「脚本丢失」就该下降对应的实例数。
/// live10149 的离线统计:AnimationObjectController 282、UnityLensFlareController 205、
/// CustomLensFlare 200、BillboardController 164、WashLightController 27、
/// MirrorBallProjector/CustomProjector/LightProjection 各 4、
/// MirrorReflection/ShaderParamController 各 1,合计 892,另有 1 个 StageController。
/// </summary>
public static class StageMissingScriptCensus
{
public static void Dump(StageController stage)
{
if (stage == null) return;
int slots = 0, missing = 0, alive = 0;
var aliveTypes = new Dictionary<string, int>();
foreach (var tr in stage.GetComponentsInChildren<Transform>(true))
{
var comps = tr.GetComponents<Component>();
foreach (var c in comps)
{
// MonoBehaviour 槽位里脚本丢失时,元素为 null。
if (c == null) { slots++; missing++; continue; }
if (c is MonoBehaviour)
{
slots++; alive++;
string n = c.GetType().Name;
aliveTypes.TryGetValue(n, out int k);
aliveTypes[n] = k + 1;
}
}
}
var parts = new List<string>();
foreach (var kv in aliveTypes) parts.Add($"{kv.Key}x{kv.Value}");
Debug.Log($"[StageScripts] MonoBehaviour 槽位 {slots}:脚本丢失 {missing},存活 {alive}" +
(parts.Count > 0 ? $"({string.Join(", ", parts)})" : "") +
"。基线 892(live10149);每按签名实现一个类,丢失数应下降对应实例数。" +
"已验证:BillboardController 令 892→728(-164)。");
}
}
/// <summary>
/// 用 live 时间轴的时间手动采样 clip。
///
/// **不能用 Animation.Play()** —— 那跑的是 Unity 自己的时钟,结果是:live 暂停了舞台
/// 照转、拖动进度条不跟随、播放速度和歌无关。舞台动画属于演出编排的一部分,
/// 必须和 <see cref="LiveTimelineControl.currentLiveTime"/> 同源,
/// 所以这里每帧设 AnimationState.time 再 Sample()。
///
/// 时间取 currentLiveTime,暂停时它不前进,拖动时它跳变,两种情况都自动正确。
/// </summary>
public class StageAnimationAutoPlay : MonoBehaviour
{
private Animation _anim;
private AnimationClip _clip;
private string _clipName;
private float _length;
private bool _loop;
public void Bind(Animation anim, string clipName)
public void Bind(Animation anim, AnimationClip clip)
{
_anim = anim;
_clipName = clipName;
if (isActiveAndEnabled) PlayClip();
_clip = clip;
_clipName = clip != null ? clip.name : null;
_length = clip != null ? clip.length : 0f;
// clip 自身的 wrapMode 决定循环与否;live10149 的 22 个 clip 里 16 个是 Loop。
_loop = clip != null && (clip.wrapMode == WrapMode.Loop || clip.wrapMode == WrapMode.PingPong);
}
private void OnEnable() => PlayClip();
private void PlayClip()
private void LateUpdate()
{
if (_anim == null || string.IsNullOrEmpty(_clipName)) return;
// 已经在播就不要重来,否则每次 SetActive 都会把循环打回起点。
if (_anim.IsPlaying(_clipName)) return;
_anim.Play(_clipName);
if (_anim == null || _clip == null || _length <= 0f) return;
var ctrl = Director.instance != null ? Director.instance._liveTimelineControl : null;
if (ctrl == null) return;
float t = ctrl.currentLiveTime;
// TODO: PingPong 按 Loop 处理,来回摆的那一半没实现(live10149 上没有 PingPong clip)。
float sampleTime = _loop ? Mathf.Repeat(t, _length) : Mathf.Clamp(t, 0f, _length);
var state = _anim[_clipName];
if (state == null) return;
state.enabled = true;
state.weight = 1f;
state.time = sampleTime;
_anim.Sample();
// 采样完就关掉,避免 Animation 自己再按 Unity 时钟推进一次。
state.enabled = false;
}
}
}
@@ -109,6 +109,9 @@ namespace Gallop.Live
// 舞台自带 Animation 的播放(原版由缺失的 AnimationObjectController 负责)。
StageAnimationPlayer.Setup(this);
// 缺失脚本普查:决定这些脚本能不能直接重新实现。
StageMissingScriptCensus.Dump(this);
// 地板发白的诊断,只打日志不改任何东西。见 StageEnvMapDiag 的说明。
StageEnvMapDiag.Dump(this);
}
+71
View File
@@ -112,6 +112,77 @@ All four failed silently, with no error. Treat a track that "works" but looks wr
**StageObjectMap:** Stage child GameObjects are indexed by name. Objects whose names match `IsTimelineControlledLight()` (`blinklight`, `spotlight3d`, `_wash_`, `laser`) start `SetActive(false)` — their handlers call `SetActive(true)` when the track fires. All other objects (neonsigns, glow meshes, UV scroll objects, etc.) keep their prefab default state.
## Ground-truth sources: what is recoverable and what is not
Mapped 2026-08-05. **Check this list before declaring something "unknown"** — several things
previously written off as un-reversible are in fact dumpable, and one whole class of problem
was solved by realising the game's own scripts can be re-bound.
### Available
| 来源 | 给出什么 | 怎么取 |
|---|---|---|
| cutt bundle TypeTree | 128 个轨道字段名 + 全部关键帧值,59 首全带 | `tools/dump_cutt_typetree.py` |
| 舞台 prefab TypeTree | GameObject 层级、组件构成,**以及缺失脚本 MonoBehaviour 的字段名和值**(离线可读) | `MetaDb().load('3d/env/live/liveNNNNN/pfb_..._controller000')` |
| shader bundle | 499 个 shader 的真实属性表(名/类型/默认值/range) | 枚举 `m_ParsedForm.m_PropInfo.m_Props` |
| material bundle | 材质的 shader 绑定与存档属性(⚠ 存档表含过期条目,见下) | `sourceresources/3d/env/live/liveNNNNN/materials/mtl_*` |
| AnimationClip | 曲线的 path / 目标属性 / 关键帧时间与值 | `m_RotationCurves` 等 |
| MonoScript | **类名 + namespace + 程序集名** | 见下,这是最重要的一条 |
### ⭐ 用原签名重建脚本,可直接拿到真实序列化字段
舞台 prefab 引用了 11 个脚本类,UmaViewer 起初只实现了 `StageController`,其余 892 个
MonoBehaviour 实例脚本为空。**但这些脚本是可以「接管」的** —— Unity 按
**类名 + namespace + 程序集名** 解析 MonoScript,而 `Assets/Scripts/umamusume.asmdef`
的程序集名正是 bundle 里写的 `umamusume`。所以只要按签名建类,字段就会被真正反序列化进来。
已验证:新建 `Gallop.Live.BillboardController` 后,`[StageScripts]` 的「脚本丢失」
从 **892 降到 728**(正好 −164 个实例),`_rotationType` 等字段直接可读。
签名清单(`Assets/Scripts/` 下任意位置均可,只要在 umamusume 程序集内):
| namespace | 类 | live10149 实例数 |
|---|---|---|
| `Gallop.Live` | `AnimationObjectController` | 282 |
| `Gallop.Live` | `BillboardController` ✅已建 | 164 |
| `Gallop.Live` | `UnityLensFlareController` | 205 |
| `Gallop.Live` | `WashLightController` | 27 |
| `Gallop.Live` | `LightProjection` | 4 |
| `Gallop.RenderPipeline` | `CustomLensFlare` | 200 |
| `Gallop.RenderPipeline` | `MirrorBallProjector` | 4 |
| `Gallop.RenderPipeline` | `CustomProjector` | 4 |
| `Gallop` | `MirrorReflection` | 1 |
| `Gallop.Live.ShaderParam` | `ShaderParamController` | 1 |
字段名必须和 TypeTree 逐字一致(大小写敏感),dump 一下即可。
这条同样适用于任何其它 bundle 里的 MonoBehaviour。
### Blocked
**IL2CPP 元数据拿不到。** 游戏是 IL2CPP 构建,但 `global-metadata.dat` 既不在
`umamusume_Data/il2cpp_data/`(那里只有 `Resources/`),也没嵌在 `GameAssembly.dll` 里
—— 魔数 `0xFAB11BAF` 零命中,`BillboardController` / `_rotationType` 等标识符字符串
也全部搜不到。这是该游戏已知的反篡改,元数据运行时才还原。
因此以下**永远拿不到标准答案**,只能靠交叉验证或承认未知:
- **所有枚举的语义** —— `_rotationType`(0/2)、`LightBlendMode`、`CmnColorType0/1`、
BlinkLight 的 `pattern`、`ColorType` 等。字段值读得到,含义读不到。
- **脚本的行为本身** —— 重建签名只能拿到**数据**,拿不到原方法体。
例:`MirrorBallLoopRotationSpeed` 的值可读,但原版怎么用它不可读。
- **速度/系数一类的常量** —— 如果原版在代码里对动画做了调速,那个倍率无从得知。
- 未随包发布的 shader(PostFilm 的定义性 shader 就在 app 二进制里)。
### 间接手段(已被证明有效)
拿不到直接答案时,**相关性交叉验证**多次奏效,比猜测可靠得多:
- BlinkLight 调色板槽位规则:靠「物件名 `lightNNN_`/`alphaNNN_` 前缀的种数」与
「调色板里去重颜色数」在**每一组都精确相等**推出,7 组无一例外。
- BgColor1 的通道 `_MulColor0`:靠枚举 shader 属性表 + 排除法确认。
- 「某属性存在」≠「目标 shader 上有该属性」—— `_Color` 空写连续栽了三次
(BlinkLight / Spotlight3d / UVScrollLight),扫一遍 shader 属性表即可批量排查。
## Working rule: no ground truth, no implementation
Field names, keyframe values and shader property tables are all recoverable from the bundles