04-sound.md · Cập nhật lần cuối: 2026-09-05 08:45 +07 · sha256 nguồn 7148609a389004 — Âm thanh (Sound)
Nguồn: chưng cất từ restore SOT
com.Chodun.CatGunner1.1.59 (BardCat). Mọi C# ở đây là L1 ANALYSIS, không phải source gốc.
1. Câu hỏi tài liệu này trả lời
Một idle-shooter bắn ~5 phát/giây và có hàng trăm nút bấm. Nếu mỗi sự kiện phát một AudioSource.PlayOneShot
thì âm thanh sẽ thành tiếng ồn trắng và CPU audio-mixer sẽ tăng vọt. CatGunner giải quyết bằng một
singleton, một enum, hai AudioSource, và ba tầng chống trùng.
2. Stable truths
| Claim | Mức bằng chứng | Nguồn / provenance |
|---|---|---|
Sound_manager : MonoBehaviour (TypeDefIndex 8080), 8 method, singleton qua property public static Sound_manager instance { get; set; } với backing field static @0x0 |
metadata-proven | dump.cs:590265 |
API phát SFX duy nhất: public void PlaySfx(SfxType id, Nullable<float> volumeOverride, float pitch = 1) |
metadata-proven | RVA 0x2ACF7D4; dump.cs:590265 |
Chỉ có hai AudioSource: [SerializeField] AudioSource sfxSource @0x20 và AudioSource bgmSource @0x28 |
metadata-proven | dump.cs:590265 |
Thư viện âm thanh là [SerializeField] SfxEntry[] sfxEntries @0x30, được lập chỉ mục thành Dictionary<SfxType, SfxEntry> sfxMap @0x40 lúc Awake |
metadata-proven | dump.cs:590265, Awake RVA 0x2ACFB34 |
Hai cơ chế chống trùng, bật/tắt độc lập từ inspector: [Header("Duplicate Guard")] bool blockSameFrameDuplicate @0x38 và [Header("Cooldown Guard")] bool blockCooldownDuplicate @0x39 + float cooldownSeconds @0x3C |
metadata-proven | dump.cs:590265 |
Trạng thái chống trùng lưu theo từng SfxType: Dictionary<SfxType,int> lastPlayedFrame @0x48 và Dictionary<SfxType,float> lastPlayedTime @0x50 |
metadata-proven | dump.cs:590265 |
Chỉ hai clip nhạc nền được tham chiếu trực tiếp: public AudioClip BGM_main @0x58 và public AudioClip BGM_contents @0x60 |
metadata-proven | dump.cs:590265 |
Chuyển nhạc bằng hai method không tham số: BGM_Change_Main() (RVA 0x2ACFEC4) và BGM_Change_SpeedRun() (RVA 0x2ACFE14) |
metadata-proven | dump.cs:590265 |
Tắt tiếng nhạc: ApplyBGMMute(bool mute) (RVA 0x2ACD5B0) — runtime chạy 3× trong cửa sổ 40 s |
metadata-proven + runtime-observed | dump.cs:590265; [runtime trace: runtime_ranking.json] |
enum SfxType (TypeDefIndex 8078) có 15 giá trị: Pop=0, Button=1, Spawn=2, Fusion=3, Upgrade=4, Dont=5, Rebirth=6, Purchase=7, Shoot=8, Select=9, Enter=10, Win=11, Fail=12, UnSelect=13, Tick=14 |
metadata-proven | dump.cs:590227 |
SFX_BtnScript (322 instance trong scene) nối SFX vào nút ở Start: GetComponent<Button>().onClick.AddListener(Sfx); Sfx() gọi Sound_manager.instance.PlaySfx(_sfx, null, 1f) |
disasm-proven + scene-census | RVA 0x2ACF6C8 / 0x2ACF774; scene census SFX_BtnScript = 322 |
SFX_BtnScript._sfx mặc định = (SfxType)1 tức Button |
disasm-proven | .ctor RVA 0x2ACFA74 |
Mute_Panel (19 instance) bật/tắt Cat_manager.is_Shoot_Mute (static bool @0x0) theo OnEnable/OnDisable |
disasm-proven + scene-census | RVA 0x2AF00DC / 0x2AF0128; dump.cs:593380 |
Runtime: Sound_manager.PlaySfx chạy 208× trong ~40 s, xấp xỉ bằng 207 phát bắn |
runtime-observed | [runtime trace: runtime_ranking.json] |
Game ship đúng 16 AudioClip, tất cả Vorbis (m_CompressionFormat = 1), export thành .ogg |
asset-census | [report: asset_lane.md] §5 |
Hai clip dài là nhạc nền: Relaxed_7_BlossomDrift (121.26 s, stereo 44.1 kHz) và Cute_5_MilkShake (105.00 s) |
asset-census | như trên |
| 14 clip còn lại ≤ 3.80 s — đều là SFX | asset-census | như trên |
Không có bundle âm thanh riêng; toàn bộ 16 clip nằm trong data.unity3d |
asset-census (bằng chứng phủ định) | [report: asset_lane.md] §5 |
Danh mục âm thanh đầy đủ (16 clip đã ship)
| Clip | ch | Hz | Độ dài | Phân loại |
|---|---|---|---|---|
Relaxed_7_BlossomDrift |
2 | 44100 | 121.26 s | nhạc nền |
Cute_5_MilkShake |
2 | 44100 | 105.00 s | nhạc nền |
etfx_explosion_chargeup2 |
2 | 48000 | 3.80 s | SFX |
Prize Wheel Spin 2 Reward |
2 | 44100 | 2.43 s | SFX |
espannol |
2 | 44100 | 2.38 s | SFX |
Positive |
1 | 44100 | 2.07 s | SFX |
Special & Powerup 45 |
2 | 44100 | 1.86 s | SFX |
Special & Powerup 43 |
2 | 44100 | 1.50 s | SFX |
Transition_In |
2 | 44100 | 0.99 s | SFX |
etfx_explosion_sharp |
2 | 48000 | 0.53 s | SFX |
Fail |
2 | 44100 | 0.40 s | SFX |
Pop 6 |
2 | 44100 | 0.30 s | SFX |
ClickyButton5a |
2 | 44100 | 0.27 s | SFX |
Popup1 |
2 | 44100 | 0.16 s | SFX |
Click |
2 | 44100 | 0.10 s | SFX |
GenericButton14 |
2 | 44100 | 0.06 s | SFX |
Không suy ra ánh xạ. 15 giá trị
SfxTypevà 14 clip SFX là một gần trùng khớp, nhưng ánh xạSfxType → AudioClipnằm trongsfxEntries[]được serialize trong scene và chưa được trích. Đừng gánSfxType.Click → "Click"chỉ vì tên giống nhau.
3. Mô hình hành vi
3.1 Kiến trúc: một cửa, một enum
bất kỳ hệ thống nào
|
v
Sound_manager.instance.PlaySfx(SfxType id, float? volumeOverride, float pitch = 1)
|
+-------+--------+
| |
sfxMap[id] chống trùng
(SfxEntry) - blockSameFrameDuplicate ? lastPlayedFrame[id] == Time.frameCount -> bỏ
- blockCooldownDuplicate ? Time.time - lastPlayedTime[id] < cooldownSeconds -> bỏ
|
v
sfxSource (MỘT AudioSource duy nhất cho toàn bộ SFX)
Ba quyết định thiết kế đáng học:
(a) Một AudioSource cho tất cả SFX. Không phải một source mỗi âm thanh, không phải pool source.
Đây là lựa chọn có đánh đổi rõ ràng: mất khả năng điều khiển từng âm riêng (dừng riêng, 3D riêng),
đổi lấy chi phí mixer cố định và không bao giờ hết voice. Với một game 2.5D nhìn từ trên xuống,
âm thanh không cần định vị không gian — đây là đánh đổi đúng.
(b) Nullable<float> volumeOverride. null nghĩa là "dùng volume mặc định của SfxEntry".
Không phải -1f hay 0f làm giá trị lính canh. Sạch, và không thể nhầm với "im lặng".
(c) pitch có giá trị mặc định 1. Tham số biến tấu cao độ nằm ngay trong API chính, không phải
một overload riêng — nên việc thêm biến tấu ngẫu nhiên (Random.Range(0.95f, 1.05f)) cho âm bắn là
một thay đổi một dòng ở call-site.
3.2 Hai tầng chống trùng, độc lập
// suy ra từ layout field (dump.cs:590265) — thân method CHƯA dựng lại
[Header("Duplicate Guard")] [SerializeField] bool blockSameFrameDuplicate; // @0x38
[Header("Cooldown Guard")] [SerializeField] bool blockCooldownDuplicate; // @0x39
[SerializeField] float cooldownSeconds; // @0x3C
readonly Dictionary<SfxType, int> lastPlayedFrame; // @0x48
readonly Dictionary<SfxType, float> lastPlayedTime; // @0x50
- Cùng frame — chặn kịch bản "10 quái chết cùng lúc, 10 tiếng nổ chồng lên nhau". Đây là loại trùng lặp gây méo tiếng nặng nhất vì các sóng cộng pha hoàn hảo.
- Cooldown — chặn kịch bản "cùng một âm phát 20 lần trong nửa giây". Mềm hơn, giữ nhịp âm thanh.
Cả hai theo từng SfxType, không phải toàn cục. Tiếng bắn bị throttle không được làm im tiếng
bấm nút. Và cả hai là cờ inspector — designer âm thanh bật/tắt được mà không cần lập trình viên.
So sánh với
FXThrottle(doc 03), vốn throttle theo thời gian + khoảng cách + trần mỗi frame. Âm thanh không cần tiêu chí khoảng cách (game không dùng âm thanh 3D) nhưng cần tiêu chí cùng-frame mà FX không cần. Hai hệ thống, hai bộ tiêu chí phù hợp với từng miền.
3.3 Nhạc nền: hai clip, hai method
public AudioClip BGM_main; // @0x58
public AudioClip BGM_contents; // @0x60
public void BGM_Change_Main() { … } // RVA 0x2ACFEC4
public void BGM_Change_SpeedRun() { … } // RVA 0x2ACFE14
public void ApplyBGMMute(bool mute){ … } // RVA 0x2ACD5B0
Chỉ hai trạng thái nhạc: "màn chính" và "nội dung phụ / speed-run". Tên field (BGM_contents) và tên
method (BGM_Change_SpeedRun) không khớp nhau — dấu hiệu clip BGM_contents phục vụ mọi chế độ phụ,
còn speed-run chỉ là cái đầu tiên cần nó.
Method không tham số — trạng thái nhạc là một phần của trạng thái game, không phải thứ call-site tự chọn. Điều này ngăn tình trạng "mỗi màn hình tự đặt nhạc của nó" và tranh chấp lẫn nhau.
ApplyBGMMute tách riêng khỏi mute SFX: người chơi tắt nhạc mà vẫn nghe hiệu ứng là nhu cầu rất phổ biến.
3.4 Nối SFX vào nút — 322 lần, không viết code
// SFX_BtnScript — RVA 0x2ACF6C8 / 0x2ACF774, 322 instance trong scene
public SfxType _sfx = (SfxType)1; // mặc định Button
private void Start() { GetComponent<Button>().onClick.AddListener(Sfx); }
private void Sfx() { Sound_manager.instance.PlaySfx(_sfx, null, 1f); }
Đây là một component 9 dòng xuất hiện 322 lần trong scene. Không có UnityEvent nối tay trong
inspector, không có onClick trỏ tới Sound_manager ở 322 chỗ khác nhau (vốn sẽ vỡ khi đổi tên method).
Chỉ cần gắn component và chọn một enum.
Mặc định = SfxType.Button nghĩa là trường hợp phổ biến nhất không cần cấu hình gì. Gắn component
lên một nút bất kỳ, nó kêu đúng ngay. Chỉ những nút đặc biệt (Purchase, Fail, Win) mới cần đổi enum.
Đây có lẽ là mẫu có tỉ lệ giá-trị/dòng-code cao nhất trong toàn bộ codebase.
3.5 Mute theo ngữ cảnh — Mute_Panel
// Mute_Panel — RVA 0x2AF00DC / 0x2AF0128, 19 instance
private void OnEnable() { Cat_manager.is_Shoot_Mute = true; }
private void OnDisable() { Cat_manager.is_Shoot_Mute = false; }
Một component 4 dòng giải quyết một vấn đề UX thật: khi người chơi mở một panel (shop, nhiệm vụ, gacha), con mèo vẫn bắn phía sau, và tiếng bắn át tiếng UI.
Cơ chế đáng chú ý:
- Nó không tắt AudioSource. Nó đặt một cờ mà bên phát ra âm thanh (Cat_manager /
Cat.Attack) đọc. Nghĩa là âm bắn không bao giờ được gửi tới mixer, thay vì được phát rồi bị chặn.
- Nó dùng OnEnable/OnDisable — vòng đời tự nhiên của panel. Không cần ai nhớ gọi mute/unmute,
và không thể kẹt ở trạng thái mute nếu panel bị tắt bằng đường khác.
- 19 instance = 19 panel khác nhau tự động có hành vi này chỉ bằng cách gắn component.
Điểm yếu cần biết khi tái dùng: đây là một bool toàn cục, không phải bộ đếm. Nếu hai panel có
Mute_Panel cùng mở và một cái đóng, cờ sẽ về false dù panel kia vẫn mở. Trong SOT điều này an toàn
vì UI là ngăn xếp một-panel-một-lúc, nhưng một framework tổng quát nên dùng counter, không dùng bool.
4. Framework takeaway
-
Một cửa duy nhất:
PlaySfx(enum id, float? volumeOverride, float pitch = 1). Enum, không phải string.Nullable<float>cho "dùng mặc định", không dùng giá trị lính canh.pitchnằm trong API chính để biến tấu ngẫu nhiên là một dòng. -
Chống trùng theo từng id, hai tiêu chí, bật/tắt từ inspector.
csharp bool blockSameFrameDuplicate; // chống cộng pha khi nhiều sự kiện cùng frame bool blockCooldownDuplicate; // chống lặp dày float cooldownSeconds; Dictionary<TId, int> lastPlayedFrame; Dictionary<TId, float> lastPlayedTime;Đây là thứ khác biệt giữa "game có âm thanh" và "game nghe được". -
Một
AudioSourcecho SFX là mặc định hợp lý cho game mobile 2D/2.5D. Framework nên cho phép nâng cấp lên pool source khi cần âm thanh 3D, nhưng không bắt trả giá đó từ đầu. -
Trạng thái nhạc nền là method không tham số, thuộc về trạng thái game.
BGM_Change_Main()/BGM_Change_X()— không để call-site tự chọn clip. -
Component nối-SFX-vào-nút với giá trị mặc định đúng. Một component nhỏ + một enum có default hợp lý thay thế hàng trăm lần nối tay trong inspector. Đây là mẫu nên có sẵn trong framework.
-
Mute theo ngữ cảnh bằng cờ mà bên phát đọc, gắn vào
OnEnable/OnDisable. Nhưng dùng counter, không dùng bool, để lồng nhau an toàn. -
Tách mute nhạc khỏi mute SFX.
-
Ngân sách âm thanh nhỏ đến bất ngờ là đủ. Một game thương mại đầy đủ ship với 16 clip (2 nhạc + 14 SFX, tổng ~4 phút âm thanh, định dạng Vorbis). Đừng nhầm số lượng clip với chất lượng cảm giác — cái tạo ra cảm giác là khi nào âm thanh phát và chống trùng, không phải bao nhiêu.
5. Negative evidence & UNKNOWN
| Mục | Trạng thái | Bằng chứng khoá được nó |
|---|---|---|
Thân PlaySfx (RVA 0x2ACF7D4) |
ĐÃ DỰNG (G410, lane feel): thứ tự cổng = is_SFX_Mute → có clip? → trùng cùng frame theo từng loại → < 0.05 s theo từng loại → volume = volumeOverride ?? volume của hàng; pitch KHÔNG BAO GIỜ biến tấu; BGM mute = volume 0 (không dừng); đổi track chỉ khi clip khác |
— |
Cấu trúc SfxEntry |
ĐÃ ĐỌC (G410): ctor blockSameFrameDuplicate = blockCooldownDuplicate = true, cooldownSeconds = 0.05; volume theo hàng: Shoot 0.3 · Fusion 0.4 · Button 0.5 · còn lại 1.0; 15 clip + 2 BGM byte-identical SOT, không mixer |
— |
Ánh xạ SfxType → AudioClip |
UNKNOWN — nằm trong sfxEntries[] serialize trong Main.unity, chưa trích. Không suy từ tên clip. |
Trích component Sound_manager từ scene YAML |
Giá trị cooldownSeconds, blockSameFrameDuplicate, blockCooldownDuplicate thực tế |
ĐÃ ĐÓNG (G410): 0.05 s / true / true (mặc định ctor; scene không ghi đè) | — |
Nơi Cat_manager.is_Shoot_Mute được đọc |
ĐÃ ĐÓNG (G363): Cat.Attack (0x2AE9624) — tiếng bắn chỉ phát khi !is_Shoot_Mute && !Setting.is_eco (mute kép: panel đang mở hoặc chế độ tiết kiệm đều tắt tiếng bắn) |
— |
SfxType.Tick (=14), Dont (=5), Fusion (=3) dùng ở đâu |
UNKNOWN — enum có giá trị nhưng call-site chưa truy | Cross-ref PlaySfx trong dump |
| Có AudioMixer / nhóm mixer không | Không quan sát thấy — Sound_manager chỉ tham chiếu hai AudioSource trần, không có AudioMixerGroup nào trong field list |
dump.cs:590265 |
| Có âm thanh 3D / spatial không | Không quan sát thấy — PlaySfx không nhận Vector3; một sfxSource duy nhất |
dump.cs:590265 |
Vì sao PlaySfx chạy 208× còn Bullet.SpawnEx 207× |
UNKNOWN (chênh 1) — có thể một tiếng UI, có thể một tiếng spawn. Không suy diễn. | Hook PlaySfx kèm tham số SfxType |
6. Raw evidence
dump.cs:590227—enum SfxType(15 giá trị)dump.cs:590265—class Sound_manager(field + 8 method + RVA)dump.cs:593380—Cat_manager.is_Shoot_Mute[reconstructed: SFX_BtnScript.cs]— nối SFX vào Button (disasm-proven)[reconstructed: Mute_Panel.cs]— mute theo ngữ cảnh (disasm-proven)[disasm: leaf_SFX_BtnScript.c], leaf_Mute_Panel.c}[runtime trace: runtime_ranking.json]—PlaySfx208×,ApplyBGMMute3×[report: asset_lane.md]§5 — danh mục 16 AudioClip đầy đủ[index: scene_census.json]—SFX_BtnScript322,Mute_Panel19
7. Đã đóng bởi FEEL lane (2026-09-05) — cập nhật cho §5
| UNKNOWN cũ | Kết quả (disasm-proven / scene) |
|---|---|
Thân PlaySfx (0x2ACF7D4) |
Thứ tự: Setting.instance.is_SFX_Mute → sfxMap.TryGetValue → same-frame (Time.frameCount) → cooldown (Time.unscaledTime, không phải Time.time) → vol = volumeOverride ?? entry.volume; pitch đặt lên sfxSource, PlayOneShot(clip, vol), trả pitch cũ. |
SfxEntry |
SfxType type; AudioClip clip; [Range(0,1)] float volume = 1f (.ctor 0x2ACFA84). Không có biến tấu pitch trong entry. |
Ánh xạ SfxType → clip (scene, project == SOT) |
Pop=Popup1 ·1 / Button=Pop 6 ·0.5 / Spawn=Special & Powerup 45 ·1 / Fusion=Prize Wheel Spin 2 Reward ·0.4 / Upgrade=Special & Powerup 43 ·1 / Dont=ClickyButton5a ·1 / Rebirth=etfx_explosion_chargeup2 ·1 / Purchase=Prize Wheel Spin 2 Reward ·1 / Shoot=etfx_explosion_sharp ·0.3 / Select=Click ·1 / Enter=Transition_In ·1 / Win=Positive ·1 / Fail=Fail ·1 / UnSelect=Click ·1 / Tick=GenericButton14 ·1 |
cooldownSeconds, hai cờ guard |
0.05 s, cả hai cờ bật — vừa là default .ctor 0x2ACFF74 vừa là giá trị scene. |
Awake |
guard singleton (Destroy bản trùng) → sfxMap.Clear() → nạp mọi entry có clip → nếu sfxSource null thì AddComponent<AudioSource> (playOnAwake=false, loop=false). |
ApplyBGMMute |
bgmSource.volume = mute ? 0 : 1 — không Stop. BGM_Change_* chỉ Play() khi clip khác. |
SfxType.Tick dùng ở đâu |
Dia_Attraction.Dia_FX_End — mỗi hạt dia về HUD. |
| Tài sản âm thanh | 15 GUID clip + 2 BGM đều resolve trong Assets/AudioClip/*.ogg, byte-identical với SOT export; không có AudioMixer group. |
Ai gọi PlaySfx |
79 call-site (danh sách: [evidence codegen: 01_callers.json]); không call-site nào truyền pitch ≠ 1. |