BlockArea 数据规格
本文档描述 BlockArea(下称「块」)的数据结构与配置参数,不含运行时行为。 行为的完整推导见 behavior.md,渲染见 render.md。
适用范围
以下内容为笔者逆向整理,不代表官方定义。逆向对象为 libil2cpp.so(ARM64)与 Unity 场景数据, 对应客户端版本 4.0.1。字段偏移取自 Il2CppDumper 生成的 dump.cs。
符号约定
本表是全文档的公共符号定义
behavior.md 与 render.md 中的符号一律以此为准。
τ:时间轴,单位为秒(
float)。- 用于全部块时间字段(
appearTime/enableTime/disableTime/disappearTime)、 事件time字段,以及destroyInterval/disabledBlockShowDuration等调参值。
与通用谱面格式的时间单位不同
judgeLineList中各类事件的startTime/endTime使用整数 T,换算为:其中 1 小节 = 4 拍 = 128 T(即 1 拍 = 32 T),BPM 每分钟拍数。 例如 BPM 120 时 32 T = 1 拍 = 0.5 秒。
而
blockAreaList的四个时间字段直接使用秒(float,含小数)。实测 1884 个块时间值中 94.5% 为非整数(如
46.666668), 而同批谱面judgeLineList的startTime全部为整数(如1152、4640)。因此不要把块的秒值当作 T 代入谱面换算。
- 用于全部块时间字段(
p:归一化进度,取值
[0, 1],由缓动表查表得出。- ⚠️ 请勿与时间轴 τ 混淆。
P:屏幕百分比长度,
P = 1表示屏幕的 100%。块的矩形由两个P坐标定义。 坐标0为屏幕左下角,1为右上角。H:画面世界高度(=
2·orthoSize=10,非像素)。S:局部长度。块是一个单位 quad(边长 1),因此其局部半边长恒为
0.5。
块的渲染完全在屏幕空间完成,不使用任何几何网格。块的几何仅由 Transform 的 localPosition 与 localScale 表达。
数据来源
块的数据就在谱面 JSON 内,是顶层字段 blockAreaList,与 judgeLineList 平级。
// 某谱面 JSON 的顶层结构
{
"formatVersion": ...,
"offset": ...,
"judgeLineList": [ ... ],
"blockAreaList": [ // ← 块
{
"topRightPercentage": { "x": 1.5, "y": 1.2 },
"bottomLeftPercentage":{ "x": 0.5, "y": -0.2 },
"appearTime": 44.0,
"enableTime": 46.666668,
"disableTime": 48.0,
"disappearTime": 49.333332,
"isSubtract": false,
"rotateEvents": [ { "anchor": {...}, "time": ..., "easeType": ..., "rotation": ... } ],
"moveEvents": [ { "endPosition": {...}, "time": ..., "easeTypeX": ..., "easeTypeY": ... } ],
"scaleEvents": [ { "anchor": {...}, "time": ..., "easeTypeX": ..., "easeTypeY": ..., "scale": {...} } ]
}
]
}运行时由 LevelControl.chart(类型 Chart)持有整个谱面对象, Chart.blockAreaList 即该数组。IL2CPP 中该字段声明类型为 List<GameInformation.BlockArea>,JSON 里表现为普通数组——容器类型与 judgeLineList 不同(后者是 Array<JudgeLine>)。
注意:块不是「通用谱面格式」的一部分
blockAreaList 是 Phigros 客户端的私有扩展字段。按通用谱面格式规范解析或 校验谱面的工具(以及通用格式文档) 不会包含它,因此按标准格式读到的谱面会「丢失」全部块。
要读块必须直接访问原始 JSON 的这个顶层键。
官方谱面实测统计
本节统计不是从本仓库附带的 APK 资源复核的
下表来自外部的 Phigros_Extractor 语料(1058 个难度文件),该语料未随文档提供。 按本项目「以 APK 为准」的约定,这些数字应视作 🔶 外部来源,尚未与本机 APK 字节交叉验证。
关键事实:本机从 APK 解出的谱面语料(textassets,1032 个 *__Chart_*.txt) 没有一份包含 blockAreaList——用通用谱面格式提取时该私有键被整段丢弃。 因此这 1032 个文件无法复现下表。
本机唯一能从 APK 字节溯源的块数据是加密包 b855456f943c1a99c25986f1c023bf47 解出的 c9s 剧情谱(blockAreaList.json,48 个块、4 个减块, 时间 58.6–67.6 s)。复现/验证应以它为准,而不是下表。
对 Phigros_Extractor 解出的 1058 个谱面 JSON(难度文件)逐一检查:
| 项 | 数量 |
|---|---|
含 blockAreaList 键的谱面文件 | 31 |
其中 blockAreaList 为非空数组 | 26 |
其中为空数组 [] | 5 |
| 涉及的歌曲数(去重) | 8 |
| 块条目总数 | 17830 |
其中 isSubtract = true | 879 |
按难度分布:AT 8 个、EZ 7 个、HD 7 个、IN 7 个、SP 1 个、Chart 1 个。
「31 个谱面」指的是难度文件,不是 31 首歌
据该外部语料,含块的只有 8 首歌:Message.くるぶっこちゃん、Ametrine.GRYSCLMIssionary、 DesultorySignals.technoplanet、Petrichor.voidMournfinale、ハテ.rNFrums、 TrueHomeTrueWorldRework.816ThreeNumbers、LeaveAllBehind.rider、c9s.ilBx0rGL。
复现验证时,这 8 首的 IN 难度块数最密集,适合做密集用例。
复现脚本
tools/_verify_block_in_json.py 打印任一谱面的 块结构;tools/_count_blocks.py 复现上表统计。
formatVersion(实测 3)只影响音符/谱面的解析布局,不参与块的时间轴: DestroyAndCreateAllBlocks(VA 0x1D7A140)把 chart.blockAreaList[i] 直接赋给 PreviewBlockControl.blockInfo,不加偏移、不做版本分支。
offset(实测 0.0)是谱面/歌曲级偏移,不逐块叠加;它经全局时钟生效: ProgressControl.Update(VA 0x1D3483C)里 nowTime(0x88) = audioTime(0xB0) − _offset(0x90), 而 _offset = levelInformation.offset。块只用 appearTime 等原始值与 nowTime 比较, 因此只有关卡级 offset 会整体平移所有块的出现时刻。
结构
根结构 Chart
本文档只描述与块相关的字段。
formatVersion
int:格式版本。offset
float:谱面偏移,单位秒。judgeLineList
Array<JudgeLine>:判定线列表。含义见通用谱面格式。blockAreaList
List<BlockArea>:块列表。私有扩展字段,缺失时视为空(该谱面无块机制)。
块 BlockArea
这是纯数据类
GameInformation.BlockArea 是 [Serializable] 的纯数据类,除 Mirror() 外不含任何执行逻辑。 块的行为完全由 PreviewBlockControl 实现。
topRightPercentage
Vector2(0x10):块的右上角屏幕百分比坐标。bottomLeftPercentage
Vector2(0x18):块的左下角屏幕百分比坐标。- 两者共同定义块的矩形。
appearTime
float(0x20):出现时刻,单位秒。enableTime
float(0x24):生效时刻,单位秒。disableTime
float(0x28):失效时刻,单位秒。disappearTime
float(0x2C):消失时刻,单位秒。四者满足
appearTime ≤ enableTime < disableTime ≤ disappearTime。四者不必满足严格不等式;相等时对应阶段被跳过。行为后果见 生命周期与阶段。
isSubtract
bool(0x30):是否为减块。false:普通块,表现为障碍物,触摸判定区向外扩张。true:减块,触摸判定区向内收缩。该字段同时决定三件事:走哪组 layer(共 3 台相机)、是否参与
SubtractBlockPostProcessor的场景色扣除、以及触摸区外扩还是内缩。注(
PreviewBlockControl.StartVA0x1D7060C反汇编):Start 只在isSubtract == true时 才写入enabledLayer/disabledLayer/readyLayer三个 layer 名; 普通块不写(保持null)。三个字符串常量取自静态槽0x4144aa0/0xa98/0xa90(字面量未解出)。 见code/PreviewBlockControl.decompiled.cs。减块不吃音符判定:音符命中由
LevelControl独立处理。减块只是视觉上挖掉一块区域, 且SpriteRenderer的 alpha 为0.1(近乎不可见),因此「吃掉音符」是视觉表现而非判定逻辑。
rotateEvents
List<RotateEvent>(0x38):旋转事件列表。moveEvents
List<MoveEvent>(0x40):移动事件列表。scaleEvents
List<ScaleEvent>(0x48):缩放事件列表。
TIP
三类事件相互独立,可同时存在,各自按时间顺序插值,最终共同决定块的变换。 移动与缩放事件的 easeTypeX / easeTypeY 可以不同。
BlockArea.Mirror()(VA 0x1CA32A8,已反汇编)
初版称「BlockArea.Mirror() 的实现未文档化」。现已从字节解出,语义为水平镜像 (与 JudgeLine.Mirror / Chart.Mirror 配套),逐字段如下(只动 x,y 一律不变):
| 字段 | 变换 | 说明 |
|---|---|---|
topRightPercentage.x(0x10) | tr.x ← 1 − bl.x | 与 bl 交叉赋值(角点互换 + 取补) |
bottomLeftPercentage.x(0x18) | bl.x ← 1 − tr.x | 同上;两式共享旧值 |
topRightPercentage.y / bottomLeftPercentage.y | 不变 | |
rotateEvents[i].anchor.x(0x10) | x ← 1 − x | 自身取补 |
rotateEvents[i].rotation(0x20) | rotation ← −rotation | 取负 |
moveEvents[i].endPosition.x(0x10) | x ← 1 − x | 自身取补 |
scaleEvents[i].anchor.x(0x10) | x ← 1 − x | 自身取补 |
// 反汇编 0x1CA32A8 还原。注意角点是「交叉」:先各读旧值,再互相写回。
float trx = topRightPercentage.x, blx = bottomLeftPercentage.x;
topRightPercentage.x = 1f - blx; // ← 用旧的 bl.x
bottomLeftPercentage.x = 1f - trx; // ← 用旧的 tr.x
// y 不变
foreach (var e in rotateEvents) { e.anchor.x = 1f - e.anchor.x; e.rotation = -e.rotation; } // 列表 null 则跳过
foreach (var e in moveEvents) { e.endPosition.x = 1f - e.endPosition.x; } // 列表 null 则跳过
foreach (var e in scaleEvents) { e.anchor.x = 1f - e.anchor.x; } // 列表 null 则跳过角点是「交叉取补」,不是「各自取补」——别写错
反汇编 0x1CA3384~0x1CA33A0 读的是 [0x18] 与 [0x10] 两个值,然后 [0x10] = 1 − 旧[0x18]、[0x18] = 1 − 旧[0x10]。即 tr/bl 的 x 互换后再取补, 不是各自 x → 1−x。语义上正好把「左/右」角点对调(保持字段名的 right/left 含义), 只翻水平轴、y 不动。事件里的 anchor.x / endPosition.x 才是自身取补。
其它细节
- 只翻 x:四个
y(角点 y、事件里的 y)都不动;没有1−y的运算。 rotation取负,解决了此前标注为「待确认」的旋转符号问题。- 每个事件列表都先判空(
null时跳过该段),不抛异常。
旋转事件 RotateEvent
| 偏移 | 字段 | 类型 | 说明 |
|---|---|---|---|
0x10 | anchor | Vector2 | 旋转锚点,单位 P |
0x18 | time | float | 事件时刻,单位秒 |
0x1C | easeType | int | 缓动类型,0~14。见缓动类型表 |
0x20 | rotation | float | 绝对目标角度,单位度,逆时针为正 |
绝对角度,不是增量
rotation 每帧被直接赋给 eulerAngles.z,不是累加。
移动事件 MoveEvent
| 偏移 | 字段 | 类型 | 说明 |
|---|---|---|---|
0x10 | endPosition | Vector2 | 目标位置,单位 P |
0x18 | time | float | 事件时刻,单位秒 |
0x1C | easeTypeX | int | 水平方向缓动类型 |
0x20 | easeTypeY | int | 垂直方向缓动类型 |
移动事件没有自己的锚点
RotateEvent 与 ScaleEvent 都带 anchor,但 MoveEvent 没有——它只有 endPosition。 移动是以块自身的锚点(topRightPercentage / bottomLeftPercentage)为基准的。
缩放事件 ScaleEvent
| 偏移 | 字段 | 类型 | 说明 |
|---|---|---|---|
0x10 | anchor | Vector2 | 缩放锚点,单位 P |
0x18 | time | float | 事件时刻,单位秒 |
0x1C | easeTypeX | int | 水平方向缓动类型 |
0x20 | easeTypeY | int | 垂直方向缓动类型 |
0x24 | scale | Vector2 | 缩放倍率 |
字段名是 scale,不是 stepX / stepY
IL2CPP 声明为 public Vector2 scale;。stepX / stepY 是静态方法 ScaleAroundAnchor(Vector2 point, Vector2 anchor, float stepX, float stepY) 的形参名, 不是序列化字段名。复现数据结构时建议直接命名为 scale 以对齐原始字段。
从形参名与 ScaleAroundAnchor 的用法看,它承担倍率角色 (1 表示不缩放)。
驱动者:PreviewBlockControl
每个块挂一个 PreviewBlockControl。它虽位于 ProjectEditor.PreviewScripts 命名空间下(Phigros 内置关卡编辑器的代码),但确实是运行时活跃组件—— 实例就在关卡场景 sharedassets12.assets(pathID 374)里,与 BlockRender(334) 和 3 个 SubtractBlockPostProcessor(331/343/350)同处一文件且同时启用。
它没有任何其他类显式调用,全部逻辑靠 Unity 的 Start / Update 消息驱动。
字段表
| 偏移 | 类型 | 字段 | 说明 |
|---|---|---|---|
0x20 | float | destroyInterval | 销毁延迟 |
0x28 | ProgressControl | progressControl | 提供 nowTime |
0x30 | LevelControl | levelControl | — |
0x38 | GameInformation.BlockArea | blockInfo | 本块数据 |
0x40 / 0x44 | float | screenWidth / screenHeight | 世界视口尺寸 (2·orthoSize·aspect, 2·orthoSize)(≈17.78 × 10),非像素 |
0x48 | int | index | — |
0x50 | SpriteRenderer | renderer | — |
0x58 | float | disabledBlockShowDuration | 预警总时长 |
0x5C | float | disabledBlockReadyDuration | 未参与判定,见下 |
0x60 | bool | isDragging | 拖拽模式已激活 |
0x68–0x80 | string ×4 | enabledLayer / disabledLayer / readyLayer / touchLayer | layer 名 |
0x88 | BlockPhase | lastPhase | 死字段:全代码从不读写 |
0x8C | bool | isDisabled | 层已切到 disabledLayer 的标记 |
0x8D | bool | wasVisible | 上帧可见(防淡入重播) |
0x8E | bool | wasReady | 上帧处于 Ready(防 reboot) |
BlockPhase 枚举(dump.cs TypeDefIndex 4924)
HiddenBefore=0, Disabled=1, Ready=2, Active=3, HiddenAfter=4。但 lastPhase(0x88) 从未被任何方法读写——阶段是每帧按 nowTime 重算布尔(wasVisible/wasReady/isDisabled)得出的, 不缓存枚举。见 behavior.md §4。
四个 layer 是按名字切换的
都是字符串,运行时通过 gameObject.layer = LayerMask.NameToLayer(name) 切换。 这解释了为什么 7 个 layer 的索引(11–17)在代码里不写死。 各 layer 的实际索引与对应相机见 render.md 图层与相机。
disabledBlockReadyDuration 就是 Ready 窗口(初版判错)
初版称「该字段(0.5)在 UpdateBlockActivation 中未被读取、用途未确认」——错误。 UpdateBlockActivation(VA 0x1D707CC)读取的正是 disabledBlockReadyDuration(0x5C):
bool ready = (enableTime - disabledBlockReadyDuration) <= now && now < enableTime;disabledBlockShowDuration(0x58)只用于 DisabledBlockShow 协程的颜色淡入时长; DisabledBlockReady 协程也读 disabledBlockReadyDuration(切 readyLayer → WaitForSeconds(disabledBlockReadyDuration) → 切 enabledLayer)。
调参
以下数值取自场景 level12 / sharedassets12.assets 的序列化数据,单位均为秒。
| 字段 | 值 | 含义 |
|---|---|---|
disabledBlockShowDuration | 0.5 | 仅等于 Ready 阶段的长度(见下) |
disabledBlockReadyDuration | 0.5 | 未参与判定,见上 |
destroyInterval | 5.0 | 消失后的销毁延迟 |
edgeSize | 1 | 边缘膨胀轮数(不是像素半径,见 render.md) |
glowRadius | 6 | 发光膨胀名义轮数(实际 5 轮) |
glowWeightFalloff | 2.65 | 发光权重衰减指数 |
glowPassWeightThreshold | 0.01 | 低于此权重的轮次跳过 |
初版列出的 blockTouchInsetScreenHeightRatio / maxBlockTouchInsetLocal 在 APK 里不存在
初版在「调参」表里列了 blockTouchInsetScreenHeightRatio = 0.03、maxBlockTouchInsetLocal = 0.25, 声称是触摸区 inset 基准。这两个字段在 dump.cs 元数据与反汇编里都找不到,也没有任何方法读取它们。 APK 的命中测试是裸半边 0.5(见 behavior.md §5.1)。 本表已删除这两行。
disabledBlockShowDuration 只管 Ready 一段
初版两处称它是「上线前完整预警窗口(Disabled + Ready 合计)」, 另一处却说「Ready 阶段的实际长度等于 disabledBlockShowDuration」——自相矛盾。
Ready=enableTime − showDuration ≤ τ < enableTime→ 长度就是showDurationDisabled=appearTime ≤ τ < enableTime − showDuration→ 长度由appearTime决定,不受它控制
所以预警总窗是 appearTime → enableTime,通常远大于 0.5 秒。 showDuration 只是「生效前多久开始显示预备态」。
这 9 个值的来源并非同一处
初版称本表「数值取自场景 level12 / sharedassets12.assets 的序列化数据」。 但本构建的 MonoBehaviour typetree 已被剥离(只剩 m_Enabled / m_Name), 组件字段无法从场景序列化读出——详见 index.md 的说明。
实际情况是分两类:
| 来源 | 字段 |
|---|---|
场景序列化(SpriteRenderer / prefab 等非脚本组件) | 触摸标记参数 size / animationDuration / idlePosition |
反汇编 + .rodata 默认值 | PreviewBlockControl 与 BlockRender 上的其余 7 个 |
TIP
disabledBlockShowDuration 是「生效前多久开始进入预备态」,当前为 0.5 秒。
触摸标记参数
按下时在手指位置实例化 touchHoverPrefab:
| 字段 | 值 |
|---|---|
size | 11.5 |
animationDuration | 0.1 |
idlePosition | (0, 0, 100)(世界坐标,缩放动画结束后的落点) |
渲染调参摘要
| 材质 | 相机 | layer | 用途 |
|---|---|---|---|
activeBlockMaterial | normalBlockCamera | NormalBlock | 普通块(生效) |
subtractBlockMaterial | subtractBlockCamera | SubtractBlock | 减块(生效) |
disabledBlockMaterial | disabledNormalBlockCamera | DisabledNormalBlock | 禁用普通块 |
disabledBlockMaterial | disabledSubtractBlockCamera | DisabledSubtractBlock | 禁用减块 |
blockReadyMaterial | disabledNormalReadyBlockCamera | DisabledNormalReadyBlock | 预备普通块 |
blockReadyMaterial | disabledSubtractReadyBlockCamera | DisabledSubtractReadyBlock | 预备减块 |
touchEffectMaterial | touchBlockCamera | TouchBlock | 触摸层 |
subtractBlockMaterial | SubtractBlockPostProcessor ×3 | — | 减块场景色扣除 |
另有 3 个不直接对应相机的材质:blockComposeMaterial、edgeMaskMaterial、 glowMaskMaterial。完整管线、材质参数与着色器见 render.md。