diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..bafaa5ae5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,52 @@ +root = true + +[*.cs] +indent_style = space +indent_size = 4 +# Match the existing Unity C# sources; keep each file's existing BOM. +end_of_line = crlf +insert_final_newline = true +trim_trailing_whitespace = true + +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = false +csharp_indent_switch_labels = true +csharp_indent_labels = one_less_than_current + +csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_parentheses = none +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_around_binary_operators = before_and_after +csharp_space_before_comma = false +csharp_space_after_comma = true +csharp_space_before_dot = false +csharp_space_after_dot = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_after_semicolon_in_for_statement = true +csharp_space_before_open_square_brackets = false +csharp_space_between_square_brackets = false +csharp_space_between_empty_square_brackets = false +csharp_preserve_single_line_statements = false +csharp_preserve_single_line_blocks = false + +# SDK whitespace formatting does not enforce spacing between members. +# These targeted StyleCop rules also run through tools/format-csharp.py. +dotnet_diagnostic.SA1107.severity = warning +dotnet_diagnostic.SA1502.severity = warning +dotnet_diagnostic.SA1507.severity = warning +dotnet_diagnostic.SA1516.severity = warning diff --git a/.github/CI.md b/.github/CI.md index 51441c10c..163281a9b 100644 --- a/.github/CI.md +++ b/.github/CI.md @@ -6,6 +6,20 @@ macOS, Linux, and 32-bit/64-bit Windows. The workflows run only on pull requests New commits cancel superseded runs for the same pull request and workflow. Successful desktop/Android builds upload artifacts. +The separate `Code style` workflow verifies changed owned C# files with the SDK's +`dotnet format`, using `.editorconfig` and the SDK pinned in `global.json`. +It combines whitespace formatting with pinned StyleCop layout rules SA1107, SA1502, +SA1507 and SA1516 to enforce separate statements, multiline blocks and blank lines +between members. StyleCop is restored only into the temporary formatting project. +It parses both PAL3 and PAL3A conditional branches without Unity or game data. +Run `python3 tools/format-csharp.py` locally to format current changes, then +`python3 tools/format-csharp.py --check` to verify them. CI selects PR changes with +`--check --base `; `--all` is an explicit full-source audit, not the default. +Default and `--base` runs apply formatting only to changed lines and deletion +boundaries, preserving existing formatting elsewhere. Explicit file/directory +paths and `--all` format whole files. Check mode operates on temporary copies. +This checks whitespace and layout, not semantic correctness or Unity behavior. + Codacy is not part of this repository's CI. Its repository webhook is disabled; there is no Codacy workflow or Codacy-required branch protection check. Re-enabling that external integration would resume its checks independently of these workflows. @@ -15,8 +29,7 @@ The builder CLI is pinned to v0.1.73 so its behavior does not change independent of the workflow. Update and validate this pin together with builder upgrades. Keep the committed project configured for PAL3. PAL3A workflows switch their own -checkout before building. Public builds reject imported private VFX and legacy Toon assets; -do not add them or original game data to CI inputs. +checkout before building. Do not add original game data to CI inputs. When investigating a failure, inspect the failed job's Unity/build logs first. Retry an isolated runner or network failure after checking the log; fix reproducible diff --git a/.github/workflows/code-style.yml b/.github/workflows/code-style.yml new file mode 100644 index 000000000..40cea5b52 --- /dev/null +++ b/.github/workflows/code-style.yml @@ -0,0 +1,29 @@ +name: Code style + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + csharp-format: + name: C# formatting (PAL3 and PAL3A) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 + with: + fetch-depth: 0 + - uses: actions/setup-dotnet@v5.3.0 + with: + global-json-file: global.json + - name: Test formatter enforcement + run: python3 tools/tests/test-format-csharp.py + - name: Verify changed C# formatting + env: + BASE_REF: ${{ github.event.pull_request.base.sha }} + run: python3 tools/format-csharp.py --check --base "$BASE_REF" diff --git a/.gitignore b/.gitignore index 730ce2f1f..30615b126 100644 --- a/.gitignore +++ b/.gitignore @@ -77,20 +77,15 @@ crashlytics-build.properties # Unity StreamingAssets folder /Assets/StreamingAssets -# 3rd party plugin/assets -/Assets/Hovl Studio +# VS Conde config file +.vsconfig + +# Local asset imports +/Assets/Hovl Studio/ /Assets/Hovl Studio.meta -/Assets/RealToon +/Assets/RealToon/ /Assets/RealToon.meta - -# VFX prefabs that are using 3rd party assets -/Assets/Resources/Prefabs/VFX/PAL3/*.prefab -/Assets/Resources/Prefabs/VFX/PAL3/*.meta -/Assets/Resources/Prefabs/VFX/PAL3A/*.prefab -/Assets/Resources/Prefabs/VFX/PAL3A/*.meta - -# Materials that are using 3rd party assets +/Assets/Resources/Prefabs/VFX/ +/Assets/Resources/Prefabs/VFX.meta /Assets/Resources/Materials/ - -# VS Conde config file -.vsconfig +/Assets/Resources/Materials.meta diff --git a/Assets/Resources/Materials.meta b/Assets/Resources/Materials.meta deleted file mode 100644 index 501cf7f04..000000000 --- a/Assets/Resources/Materials.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 0b0b27c9e5e4d2e4fbc9b6584f828f7b -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Resources/Prefabs/VFX.meta b/Assets/Resources/Prefabs/VFX.meta deleted file mode 100644 index 33f0c466d..000000000 --- a/Assets/Resources/Prefabs/VFX.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 2eaeb845461401e488e4640caf779ddf -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Resources/Prefabs/VFX/PAL3.meta b/Assets/Resources/Prefabs/VFX/PAL3.meta deleted file mode 100644 index 8dab3f17e..000000000 --- a/Assets/Resources/Prefabs/VFX/PAL3.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: b93923849ec63ad4ea52f79bf5bc9cb6 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Resources/Prefabs/VFX/PAL3/README.txt b/Assets/Resources/Prefabs/VFX/PAL3/README.txt deleted file mode 100644 index 702dc725b..000000000 --- a/Assets/Resources/Prefabs/VFX/PAL3/README.txt +++ /dev/null @@ -1,45 +0,0 @@ -因为当前特效的实现方式使用了第三方插件和素材,所以此文件夹内的所有prefab均添加进了gitignore里。 -请联系作者了解插件获取方式和特效prefab的获取方式。 - -0 => 传送法阵特效 - -15 => 单人传送特效 -98 => 重楼施法帮龙葵恢复肉身时的特效 -175 => 邪剑仙迷倒众人的特效 -224 => 丁小侠剧情狼妖受击特效 -251 => 邪剑仙变身特效 -252 => 邪剑仙传送离开时的特效 -254 => 重楼传送法术特效 -255 => 龙葵穿衣服的特效 -256 => 龙葵治疗他人特效 -257 => 治疗咒施法特效 -259 => 大型传送法施法前奏特效 -260 => 大型传送法阵特效收尾 -261 => 大型传送法阵特效开始 -262 => 重楼当剑的时候剑插入地面的火光特效 -263 => 火鬼王消失特效 -264 => 古藤精施法特效 -265 => 人物受到法术作用时候的特效 -266 => 邪剑仙施法特效 -270 => 天鬼皇复活后灵魂出窍特效 -275 => 重楼创建的用来禁锢雪见/龙葵的法阵特效 -276 => 迴魂施法结束特效 -277 => 迴魂施法完全结束特效 -278 => 迴魂施法特效 -290 => 雪见传送出现时特效 -295 => 重楼施法治疗特效 -297 => 紫萱施法召唤水灵特效 -337 => 古藤精风系法术特效 -341 => 邪剑仙给镇妖剑施法特效 -368 => 雷击单体施法特效 -369 => 雷击范围施法特效 -376 => 紫萱的土系法术特效 -389 => 紫萱给江姑娘施法特效 -452 => 花楹治疗他人特效 -453 => 人物施法蓄力时的特效 -454 => 龙葵施法让众人御剑飞行的特效 -456 => 老婆婆施法特效 -457 => 夕瑶给景天施法时的特效 -458 => 灵力溢出特效 -467 => 邪剑仙灰飞烟灭特效 -468 => 蓝龙葵变红龙葵的特效 \ No newline at end of file diff --git a/Assets/Resources/Prefabs/VFX/PAL3/README.txt.meta b/Assets/Resources/Prefabs/VFX/PAL3/README.txt.meta deleted file mode 100644 index e2b289277..000000000 --- a/Assets/Resources/Prefabs/VFX/PAL3/README.txt.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 815b64a4a03e7b949bd85e1e5c19deb3 -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Resources/Prefabs/VFX/PAL3A.meta b/Assets/Resources/Prefabs/VFX/PAL3A.meta deleted file mode 100644 index 1494d7c9c..000000000 --- a/Assets/Resources/Prefabs/VFX/PAL3A.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: a637ebcead3359c4f849f936d41707ee -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Resources/Prefabs/VFX/PAL3A/README.txt b/Assets/Resources/Prefabs/VFX/PAL3A/README.txt deleted file mode 100644 index 008c8235b..000000000 --- a/Assets/Resources/Prefabs/VFX/PAL3A/README.txt +++ /dev/null @@ -1,67 +0,0 @@ -因为当前特效的实现方式使用了第三方插件和素材,所以此文件夹内的所有prefab均添加进了gitignore里。 -请联系作者了解插件获取方式和特效prefab的获取方式。 - -0 => 传送法阵特效 -9 => 茅山道士封印法术特效 -40 => 单体风咒特效 -50 => 单体雷咒特效 -91 => 封印法术特效 -157 => 角色传送出现特效 -164 => 南宫煌变身狼形态时的特效 -170 => 王蓬絮变身特效 -171 => 人物施法特效 -172 => 人物受到法术作用时候的特效 -173 => 人物受到法术作用时候的特效 -174 => 治疗特效 -175 => 茅山术士打乱三杯盏特效 -176 => 南宫煌打乱三杯盏特效 -177 => 角色传送消失时的特效 -178 => 南宫煌使关桃子的笼子消失的特效 -179 => 南宫煌变出法桌的特效 -181 => 南宫煌变出小东西的特效 -182 => 南宫煌变出大东西的特效 -186 => 大石块砸下后的尘土特效 -189 => 地脉石门封印打开时的特效 -198 => 南宫煌变出东西的特效 -201 => 魁妖变小特效 -205 => 照妖镜发光的特效 -209 => 五灵轮特效 -211 => 洒金笺特效 -230 => 重楼施法消灭怪物的特效 -254 => 重楼传送特效 -257 => 隔空取物特效 -258 => 迴魂传送特效 -259 => 大型传送法施法前奏特效 -260 => 大型传送法阵特效收尾 -261 => 大型传送法阵特效开始 -281 => 角色传送消失时的特效 -282 => 雷咒特效 -283 => 物品消失时的特效 -285 => 南宫煌施法特效 -287 => 土灵珠特效 -289 => 血魂姬施法特效 -290 => 回复法阵特效 -292 => 五灵轮发光特效 -299 => 重楼传送众人特效 -314 => 倾国银弹波特效 -315 => 南宫煌切换为人形态特效 -316 => 魔物出现特效 -318 => 千凝魔艮特效 -320 => 幽娘变身特效 -322 => 幽娘吸收怨气特效 -323 => 幽娘收集怨气特效 -325 => 陆北亭变身特效 -328 => 风雅颂开心的特效 -333 => 感月出现特效 -355 => 王蓬絮治疗特效 -359 => 蝎魃消失特效 -360 => 星璇给南宫煌输入内力时的特效 -367 => 五毒珠治疗特效 -388 => 迴魂传送特效 -389 => 迴魂传送特效 -397 => 燎日变身特效 -401 => 魔尊帮星璇灵肉合一的特效 -404 => 思堂消失特效 -408 => 星璇燎日变换时特效 -424 => 王蓬絮对南宫煌施法特效 - diff --git a/Assets/Resources/Prefabs/VFX/PAL3A/README.txt.meta b/Assets/Resources/Prefabs/VFX/PAL3A/README.txt.meta deleted file mode 100644 index eb55f0fc1..000000000 --- a/Assets/Resources/Prefabs/VFX/PAL3A/README.txt.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: f1d950c46b3119043a1919074fdc27c5 -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Resources/Shaders/Pal3OriginalEffect.shader b/Assets/Resources/Shaders/Pal3OriginalEffect.shader new file mode 100644 index 000000000..3b79142a7 --- /dev/null +++ b/Assets/Resources/Shaders/Pal3OriginalEffect.shader @@ -0,0 +1,78 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +Shader "Pal3/OriginalEffect" +{ + Properties + { + _MainTex ("Texture", 2D) = "white" {} + [Enum(UnityEngine.Rendering.BlendMode)] _SrcBlend ("Source blend", Float) = 1 + [Enum(UnityEngine.Rendering.BlendMode)] _DstBlend ("Destination blend", Float) = 1 + [Enum(UnityEngine.Rendering.CompareFunction)] _ZTest ("Depth test", Float) = 4 + [Enum(UnityEngine.Rendering.CullMode)] _Cull ("Cull", Float) = 0 + _AlphaMode ("Original alpha operation", Float) = 0 + } + SubShader + { + Tags + { + "RenderPipeline" = "UniversalPipeline" + "Queue" = "Transparent" + "RenderType" = "Transparent" + } + Pass + { + Tags { "LightMode" = "SRPDefaultUnlit" } + Blend [_SrcBlend] [_DstBlend] + ZTest [_ZTest] + ZWrite Off + Cull [_Cull] + HLSLPROGRAM + #pragma vertex Vert + #pragma fragment Frag + #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" + struct Attributes + { + float3 positionOS : POSITION; + float2 uv : TEXCOORD0; + half4 color : COLOR; + }; + struct Varyings + { + float4 positionCS : SV_POSITION; + float2 uv : TEXCOORD0; + half4 color : COLOR; + }; + TEXTURE2D(_MainTex); + SAMPLER(sampler_MainTex); + CBUFFER_START(UnityPerMaterial) + float _AlphaMode; + CBUFFER_END + Varyings Vert(Attributes i) + { + Varyings o; + o.positionCS = TransformObjectToHClip(i.positionOS); + o.uv = i.uv; + o.color = i.color; + return o; + } + half4 Frag(Varyings i) : SV_Target + { + half4 tex = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv); + half4 col = tex * i.color; + if (_AlphaMode > 1.5) + { + col.a = tex.a; + } + else if (_AlphaMode > 0.5) + { + col.a = saturate(tex.a + i.color.a); + } + return col; + } + ENDHLSL + } + } +} diff --git a/Assets/Resources/Shaders/Pal3OriginalEffect.shader.meta b/Assets/Resources/Shaders/Pal3OriginalEffect.shader.meta new file mode 100644 index 000000000..020d270c6 --- /dev/null +++ b/Assets/Resources/Shaders/Pal3OriginalEffect.shader.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: dd7af60dc97334317a8bf7d560ba0f71 +ShaderImporter: + externalObjects: {} + defaultTextures: [] + nonModifiableTextures: [] + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Editor/EffectEditor.cs b/Assets/Scripts/Editor/EffectEditor.cs new file mode 100644 index 000000000..2777699f2 --- /dev/null +++ b/Assets/Scripts/Editor/EffectEditor.cs @@ -0,0 +1,681 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Editor +{ + using System; + using System.IO; + using System.Linq; + using Pal3.Core.DataReader.Cpk; + using Pal3.Core.DataReader.Data; + using Pal3.Core.DataReader.Par; + using Pal3.Core.FileSystem; + using Pal3.Game.Effect.Original; + using UnityEditor; + using UnityEditor.SceneManagement; + using UnityEngine; + using UnityEngine.Rendering; + using UnityEngine.Rendering.Universal; + using UnityEngine.SceneManagement; + using UnityEngine.UIElements; + + public sealed class EffectEditor : EditorWindow + { + private OriginalEffectResources _resources; + private OriginalEffectPlayer _player; + private OriginalPortalEffect _portal; + private uint _portalType; + [SerializeField] private OriginalEffectOverrides _edits; + [SerializeField] private string _assetPath = "", _savedJson; + private GameObject _root; + private Scene _scene; + private Camera _camera; + private RenderTexture _target; + private string _dataPath, _search = "", _error; + [SerializeField] private bool _pal3a; + + private string Variant => _pal3a ? "PAL3A" : "PAL3"; + + private int _codepage = 936; + [SerializeField] private int _id = 98, _objectIndex; + private Vector2 _listScroll, _inspectorScroll, _orbit = new(135, 15); + private float _distance = 14, _speed = 1, _duration = 15; + [SerializeField] private float _time; + private bool _playing, _loop; + private uint _seed = 1; + private bool _emitterCurve; + private int _particleIndex, _curveIndex; + private double _lastUpdate; + private string _previewJson; + private float? _pendingSeek; + private double _seekAfter; + + [MenuItem("PAL3/Effect Editor")] + public static void Open() + { + var window = GetWindow("Effects"); + window.ChangeVariant(false); + } + + [MenuItem("PAL3A/Effect Editor")] + public static void OpenPal3A() + { + var window = GetWindow("Effects"); + window.ChangeVariant(true); + } + + private void ChangeVariant(bool pal3a) + { + if (_pal3a == pal3a || !CanReplaceEdits()) + return; + _pal3a = pal3a; + _id = pal3a ? 160 : 98; + _time = 0; + LoadOverrides(Resources.Load($"Effects/{Variant}Overrides")); + LoadPreferences(); + LoadData(); + } + + private void LoadPreferences() + { + _dataPath = EditorPrefs.GetString($"{Variant}.OriginalEffect.DataPath", Path.GetFullPath(Path.Combine(Application.dataPath, $"../../Game Data/{Variant}"))); + _codepage = EditorPrefs.GetInt($"{Variant}.OriginalEffect.Codepage", 936) == 950 ? 950 : 936; + } + + public void CreateGUI() => rootVisualElement.Add(new IMGUIContainer(DrawWindow) { style = { flexGrow = 1 } }); + + private void OnEnable() + { + minSize = new Vector2(1050, 640); + LoadPreferences(); + if (_edits == null) + LoadOverrides(Resources.Load($"Effects/{Variant}Overrides")); + _edits.hideFlags = HideFlags.HideInHierarchy | HideFlags.DontSave; + saveChangesMessage = "Save the original effect overrides before closing?"; + _scene = EditorSceneManager.NewPreviewScene(); + var cameraObject = new GameObject("Effect preview camera") { hideFlags = HideFlags.HideAndDontSave }; + SceneManager.MoveGameObjectToScene(cameraObject, _scene); + _camera = cameraObject.AddComponent(); + _camera.enabled = false; + _camera.clearFlags = CameraClearFlags.SolidColor; + _camera.backgroundColor = new Color(0.07f, 0.08f, 0.1f); + _camera.fieldOfView = 40; + _camera.nearClipPlane = 0.01f; + _camera.farClipPlane = 500; + _camera.scene = _scene; + _lastUpdate = EditorApplication.timeSinceStartup; + EditorApplication.update += Tick; + Undo.undoRedoPerformed += RefreshAfterUndo; + if (Directory.Exists(_dataPath)) + LoadData(); + } + + private void OnDisable() + { + EditorApplication.update -= Tick; + Undo.undoRedoPerformed -= RefreshAfterUndo; + ClearPreview(); + _resources?.Dispose(); + _resources = null; + if (_target != null) + { + _target.Release(); + DestroyImmediate(_target); + } + if (_scene.IsValid()) + EditorSceneManager.ClosePreviewScene(_scene); + } + + private void OnDestroy() + { + if (_edits != null) + { + Undo.ClearUndo(_edits); + DestroyImmediate(_edits); + } + } + + private void LoadOverrides(OriginalEffectOverrides asset) + { + if (_edits != null) + { + Undo.ClearUndo(_edits); + DestroyImmediate(_edits); + } + _edits = asset != null ? Instantiate(asset) : CreateInstance(); + _edits.hideFlags = HideFlags.HideInHierarchy | HideFlags.DontSave; + _assetPath = asset != null ? AssetDatabase.GetAssetPath(asset) : ""; + _savedJson = JsonUtility.ToJson(_edits); + hasUnsavedChanges = false; + } + + private void MarkChanged() => hasUnsavedChanges = JsonUtility.ToJson(_edits) != _savedJson; + + public override void SaveChanges() + { + if (SaveOverrides()) + base.SaveChanges(); + } + + public override void DiscardChanges() + { + LoadOverrides(_assetPath.Length > 0 ? AssetDatabase.LoadAssetAtPath(_assetPath) : null); + base.DiscardChanges(); + RefreshAfterUndo(); + } + + private bool CanReplaceEdits() + { + if (!hasUnsavedChanges) + return true; + int choice = EditorUtility.DisplayDialogComplex("Original effect overrides", "Save your current edits before loading another asset?", "Save", "Cancel", "Discard"); + return choice == 2 || choice == 0 && SaveOverrides(); + } + + private bool SaveOverrides() + { + string path = EditorUtility.SaveFilePanelInProject($"Save {Variant} effect overrides", + _assetPath.Length > 0 ? Path.GetFileNameWithoutExtension(_assetPath) : $"{Variant}Overrides", "asset", + $"Use Assets/Resources/Effects/{Variant}Overrides.asset for runtime loading."); + if (path.Length == 0) + return false; + var existing = AssetDatabase.LoadMainAssetAtPath(path); + if (existing != null && existing is not OriginalEffectOverrides) + { + _error = "Choose an OriginalEffectOverrides asset or a new path."; + return false; + } + var saved = existing as OriginalEffectOverrides; + if (saved != null) + { + Undo.RecordObject(saved, $"Save {Variant} effect overrides"); + EditorUtility.CopySerialized(_edits, saved); + saved.hideFlags = HideFlags.None; + EditorUtility.SetDirty(saved); + } + else + { + saved = Instantiate(_edits); + saved.hideFlags = HideFlags.None; + AssetDatabase.CreateAsset(saved, path); + } + saved.name = Path.GetFileNameWithoutExtension(path); + EditorUtility.SetDirty(saved); + AssetDatabase.SaveAssets(); + _assetPath = path; + _savedJson = JsonUtility.ToJson(_edits); + hasUnsavedChanges = false; + return true; + } + + private void LoadData() + { + float time = _time; + ClearPreview(); + _resources?.Dispose(); + _resources = null; + _error = null; + try + { + var hash = new Crc32Hash(); + hash.Init(); + var fs = new CpkFileSystem(_dataPath, hash, _codepage); + fs.Mount("basedata/basedata.cpk", _codepage); + _resources = new OriginalEffectResources(fs, _codepage); + if (_resources.GameVariant != Variant) + throw new InvalidDataException($"The selected directory contains {_resources.GameVariant} data; select the matching variant first."); + EditorPrefs.SetString($"{Variant}.OriginalEffect.DataPath", _dataPath); + Restart(); + Seek(time); + } + catch (Exception ex) + { + _resources?.Dispose(); + _resources = null; + _error = ex.ToString(); + } + } + + private void ClearPreview() + { + if (_root != null) + DestroyImmediate(_root); + _player = null; + _portal = null; + } + + private void Restart() + { + ClearPreview(); + _time = 0; + _pendingSeek = null; + _error = null; + _previewJson = JsonUtility.ToJson(_edits); + if (_resources == null) + return; + try + { + _root = new GameObject("Original effect preview") { hideFlags = HideFlags.HideAndDontSave }; + SceneManager.MoveGameObjectToScene(_root, _scene); + if (_id < 0) + { + _portal = _root.AddComponent(); + _portal.AutoAdvance = false; + _portal.Initialize(_resources, _portalType); + } + else + { + string[] issues = _resources.Diagnose(_id, _edits); + if (issues.Length > 0) + _error = string.Join("\n", issues); + else + { + _player = _root.AddComponent(); + _player.AutoAdvance = false; + _player.Initialize(_resources, _id, _seed, _edits); + _player.Loop = false; + if (_player.Error != null) + _error = _player.Error; + } + } + } + catch (Exception ex) + { + ClearPreview(); + _error = ex.ToString(); + } + } + + private void Tick() + { + double now = EditorApplication.timeSinceStartup; + float dt = Mathf.Min(0.1f, (float)(now - _lastUpdate)); + _lastUpdate = now; + if (_pendingSeek.HasValue && now >= _seekAfter) + { + Seek(_pendingSeek.Value); + Repaint(); + } + if (_playing) + { + Advance(dt * _speed); + if (_time >= _duration) + { + if (_loop) + Restart(); + else + _playing = false; + } + Repaint(); + } + } + + private void Advance(float delta) + { + _time += delta; + _player?.Advance(delta); + _portal?.Sample(_time); + } + + private void Seek(float time) + { + _pendingSeek = null; + _time = time; + _player?.Seek(time); + _portal?.Sample(time); + } + + private void QueueSeek(float time) + { + _playing = false; + _pendingSeek = time; + _seekAfter = EditorApplication.timeSinceStartup + 0.05; + Repaint(); + } + + private void RefreshAfterUndo() + { + MarkChanged(); + if (_previewJson == JsonUtility.ToJson(_edits)) + return; + float time = _time; + Restart(); + Seek(time); + Repaint(); + } + + private void DrawWindow() + { + using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) + { + int variant = EditorGUILayout.Popup(_pal3a ? 1 : 0, new[] { "PAL3", "PAL3A" }, GUILayout.Width(80)); + if ((variant == 1) != _pal3a) + ChangeVariant(variant == 1); + _dataPath = EditorGUILayout.TextField(_dataPath); + int codepage = EditorGUILayout.Popup(_codepage == 950 ? 1 : 0, new[] { "GBK (936)", "Big5 (950)" }, GUILayout.Width(105)) == 0 ? 936 : 950; + if (codepage != _codepage) + { + _codepage = codepage; + EditorPrefs.SetInt($"{Variant}.OriginalEffect.Codepage", _codepage); + LoadData(); + } + if (GUILayout.Button("Browse", EditorStyles.toolbarButton, GUILayout.Width(60))) + { + string path = EditorUtility.OpenFolderPanel($"{Variant} data (contains basedata)", _dataPath, ""); + if (path.Length > 0) + { + _dataPath = path; + LoadData(); + } + } + if (GUILayout.Button("Reload", EditorStyles.toolbarButton, GUILayout.Width(55))) + LoadData(); + } + var asset = _assetPath.Length > 0 ? AssetDatabase.LoadAssetAtPath(_assetPath) : null; + var selected = (OriginalEffectOverrides)EditorGUILayout.ObjectField("Overrides", asset, typeof(OriginalEffectOverrides), false); + if (selected != asset && CanReplaceEdits()) + { + LoadOverrides(selected); + Restart(); + } + if (_resources == null) + { + EditorGUILayout.HelpBox(_error ?? "Choose the PAL3 game data directory.", MessageType.Info); + return; + } + using (new EditorGUILayout.HorizontalScope()) + { + using (new EditorGUILayout.VerticalScope(GUILayout.Width(185))) + { + _search = EditorGUILayout.TextField(_search, EditorStyles.toolbarSearchField); + _listScroll = EditorGUILayout.BeginScrollView(_listScroll); + if (GUILayout.Button("Scene: Transport point")) + Select(-1); + for (int id = 0; id < _resources.Definitions.Groups.Length; id++) + { + EffectGroup g = _resources.Definitions.Groups[id]; + if (g.Objects.Length == 0) + continue; + string label = $"{id} ({g.Objects.Length})"; + if (_search.Length > 0 && !label.Contains(_search) && !g.Objects.Any(o => _resources.Definitions.TryGetType(o.TypeCrc, out var d) && d.TypeName.Contains(_search))) + continue; + if (GUILayout.Toggle(_id == id, label, "Button") && _id != id) + Select(id); + } + EditorGUILayout.EndScrollView(); + } + using (new EditorGUILayout.VerticalScope()) + { + if (_id < 0 && _pal3a) + { + int style = EditorGUILayout.Popup("Transport style", (int)_portalType, new[] { "Default", "1 (trans_s)", "2 (trans_m)", "3 (trans_l)", "4 (trans_f)", "5 (trans_t)", "6 (trans_p)" }); + if ((uint)style != _portalType) + { + _portalType = (uint)style; + Restart(); + } + } + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button(_playing ? "Pause" : "Play", GUILayout.Width(60))) + _playing = !_playing; + if (GUILayout.Button("Reset", GUILayout.Width(55))) + Restart(); + if (GUILayout.Button("Step", GUILayout.Width(50))) + { + _playing = false; + Advance(OriginalEffectPlayer.FixedStep); + Repaint(); + } + GUILayout.Label("Speed", GUILayout.Width(40)); + _speed = EditorGUILayout.FloatField(_speed, GUILayout.Width(45)); + _speed = float.IsFinite(_speed) ? Mathf.Clamp(_speed, 0, 16) : 1; + _loop = GUILayout.Toggle(_loop, "Loop"); + } + Rect preview = GUILayoutUtility.GetRect(200, 200, GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true)); + DrawPreview(preview); + float displayedTime = _pendingSeek ?? _time; + float time = EditorGUILayout.Slider("Time", displayedTime, 0, _duration); + if (Mathf.Abs(time - displayedTime) > 0.00001f) + QueueSeek(time); + using (new EditorGUILayout.HorizontalScope()) + { + _duration = EditorGUILayout.FloatField("Preview duration", _duration); + _duration = float.IsFinite(_duration) ? Mathf.Clamp(_duration, 0.1f, 600) : 15; + uint seed = (uint)Mathf.Max(1, EditorGUILayout.IntField("Seed", (int)_seed)); + if (seed != _seed) + { + _seed = seed; + Restart(); + } + } + EditorGUILayout.LabelField($"Objects: {_player?.NodeCount ?? 0} Quads/particles: {_player?.QuadCount ?? 0} {_player?.Error}"); + EditorGUILayout.LabelField("Drag to orbit · Scroll to zoom", EditorStyles.miniLabel); + if (_error != null) + EditorGUILayout.HelpBox(_error, MessageType.Warning); + } + using (new EditorGUILayout.VerticalScope(GUILayout.Width(310))) + DrawInspector(); + } + } + + private void Select(int id) + { + _id = id; + _objectIndex = 0; + Restart(); + } + + private void DrawPreview(Rect rect) + { + Event e = Event.current; + if (rect.Contains(e.mousePosition)) + { + if (e.type == EventType.MouseDrag && e.button == 0) + { + _orbit += e.delta * new Vector2(0.5f, -0.5f); + e.Use(); + Repaint(); + } + if (e.type == EventType.ScrollWheel) + { + _distance = Mathf.Clamp(_distance * Mathf.Exp(e.delta.y * 0.08f), 0.3f, 200); + e.Use(); + Repaint(); + } + } + if (e.type != EventType.Repaint || rect.width < 1 || rect.height < 1) + return; + int w = Mathf.Max(1, (int)rect.width), h = Mathf.Max(1, (int)rect.height); + if (_target == null || _target.width != w || _target.height != h) + { + if (_target != null) + { + _target.Release(); + DestroyImmediate(_target); + } + _target = new RenderTexture(w, h, 24) { hideFlags = HideFlags.HideAndDontSave }; + _target.Create(); + } + _camera.transform.position = Quaternion.Euler(_orbit.y, _orbit.x, 0) * new Vector3(0, 0, -_distance) + Vector3.up * 1.5f; + _camera.transform.LookAt(Vector3.up * 1.5f); + _camera.aspect = (float)w / h; + _player?.Render(_camera); + RenderPipeline.SubmitRenderRequest(_camera, new UniversalRenderPipeline.SingleCameraRequest { destination = _target }); + GUI.DrawTexture(rect, _target, ScaleMode.StretchToFill, false); + GUI.Label(new Rect(rect.x + 8, rect.y + 8, rect.width - 16, 25), $"{Variant} effect data", EditorStyles.whiteBoldLabel); + } + + private void DrawInspector() + { + EditorGUILayout.LabelField("Object / task parameters", EditorStyles.boldLabel); + if (_id < 0) + { + EditorGUILayout.HelpBox("MXTransport: original two cones and rotating plane. This is separate from flexeff group 0.", MessageType.Info); + return; + } + EffectGroup group = _resources.Definitions.Groups[_id]; + if (group.Objects.Length == 0) + return; + _objectIndex = EditorGUILayout.IntSlider("Object", _objectIndex, 0, group.Objects.Length - 1); + EffectGroupObject source = group.Objects[_objectIndex]; + _resources.Definitions.TryGetType(source.TypeCrc, out EffectDefinition d); + EditorGUILayout.LabelField($"{d.TypeName} / {EffectClass.Name(d.ClassNameCrc)}"); + EditorGUILayout.SelectableLabel(d.TextureName ?? "", GUILayout.Height(35)); + OriginalEffectObjectOverride edit = _edits.Find(_id, _objectIndex); + if (edit == null && GUILayout.Button("Edit this object")) + { + Undo.RecordObject(_edits, "Edit original effect"); + edit = new OriginalEffectObjectOverride + { + GroupId = _id, + ObjectIndex = _objectIndex, + Definition = JsonUtility.FromJson(JsonUtility.ToJson(d)), + Tasks = source.Tasks.Select(t => JsonUtility.FromJson(JsonUtility.ToJson(t))).ToArray() + }; + _edits.Objects.Add(edit); + MarkChanged(); + } + _inspectorScroll = EditorGUILayout.BeginScrollView(_inspectorScroll); + if (edit != null) + { + var so = new SerializedObject(_edits); + so.Update(); + SerializedProperty entry = so.FindProperty("Objects").GetArrayElementAtIndex(_edits.Objects.IndexOf(edit)); + EditorGUILayout.PropertyField(entry.FindPropertyRelative("Hidden")); + EditorGUILayout.PropertyField(entry.FindPropertyRelative("Definition"), true); + EditorGUILayout.PropertyField(entry.FindPropertyRelative("Tasks"), true); + if (so.ApplyModifiedProperties()) + { + MarkChanged(); + float time = _time; + Restart(); + Seek(time); + } + } + else + foreach (EffectTask t in source.Tasks) + EditorGUILayout.LabelField($"{t.StartTime:0.###}s + {t.Duration:0.###}s loops={t.RequireLoops}"); + EffectDefinition effective = edit?.Definition ?? d; + if (EffectClass.Name(effective.ClassNameCrc) == "S-Particle") + { + try + { + SparFile spar = _resources.Spar(effective.ParticleFileName); + EditorGUILayout.LabelField($"SPar: {spar.Groups.Length} groups / shader {spar.Shader}"); + foreach (SparGroup sg in spar.Groups) + { + EditorGUILayout.LabelField($"{sg.Name}: capacity {sg.Capacity}, {sg.Keyframes.Length} keys", EditorStyles.wordWrappedLabel); + EditorGUILayout.SelectableLabel(sg.Texture, GUILayout.Height(30)); + } + EditorGUILayout.HelpBox("Native SPar playback: keyframes, emitter paths, particle controllers and original shader blending. SPar fields are read-only here.", MessageType.Info); + } + catch (Exception ex) + { + EditorGUILayout.HelpBox(ex.Message, MessageType.Warning); + } + } + if (EffectClass.Name(effective.ClassNameCrc) == "particle") + DrawParticleCurves(effective.ParticleFileName); + EditorGUILayout.EndScrollView(); + if (GUILayout.Button("Save overrides for game…")) + SaveOverrides(); + if (GUILayout.Button("Discard edits for this object") && edit != null) + { + Undo.RecordObject(_edits, "Discard original effect override"); + _edits.Objects.Remove(edit); + MarkChanged(); + Restart(); + } + } + + private void DrawParticleCurves(string path) + { + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Particle resource curves", EditorStyles.boldLabel); + ParticleFile file; + try + { + file = _resources.Particle(path); + } + catch (Exception ex) + { + EditorGUILayout.HelpBox(ex.Message, MessageType.Warning); + return; + } + _emitterCurve = EditorGUILayout.Toggle("Emitter curves", _emitterCurve); + int count = _emitterCurve ? file.Emitters.Length : file.Particles.Length; + if (count == 0) + return; + _particleIndex = EditorGUILayout.IntSlider("Resource index", _particleIndex, 0, count - 1); + string[] names = _emitterCurve ? new[] { "Direction", "RandomVelocity", "Emission", "Life" } : new[] { "Size", "Rotation", "Alpha", "Color" }; + _curveIndex = EditorGUILayout.Popup("Curve", Mathf.Clamp(_curveIndex, 0, names.Length - 1), names); + string name = names[_curveIndex]; + ParticleCurve source = OriginalEffectOverrides.FindCurve(file, _emitterCurve, _particleIndex, name); + var entry = _edits.ParticleCurves.Find(c => string.Equals(c.File, path, StringComparison.OrdinalIgnoreCase) + && c.Emitter == _emitterCurve && c.Index == _particleIndex && c.Parameter == name); + bool validEntry = entry == null || entry.Channels != null && entry.Channels.Length == source.Channels && entry.Channels.All(c => c != null); + if (!validEntry) + EditorGUILayout.HelpBox("This curve override has invalid channels. Restore the original curve to repair it.", MessageType.Warning); + for (int channel = 0; channel < source.Channels; channel++) + { + if (!validEntry) + break; + AnimationCurve curve = entry != null ? CopyCurve(entry.Channels[channel]) : SampleCurve(source, channel); + EditorGUI.BeginChangeCheck(); + AnimationCurve changed = EditorGUILayout.CurveField($"Channel {channel}", curve); + if (EditorGUI.EndChangeCheck()) + { + Undo.RecordObject(_edits, "Edit particle curve"); + if (entry == null) + { + entry = new OriginalParticleCurveOverride + { + File = path, + Parameter = name, + Index = _particleIndex, + Emitter = _emitterCurve, + Channels = Enumerable.Range(0, source.Channels).Select(c => SampleCurve(source, c)).ToArray() + }; + _edits.ParticleCurves.Add(entry); + } + entry.Channels[channel] = CopyCurve(changed); + MarkChanged(); + float time = _time; + Restart(); + Seek(time); + } + } + EditorGUILayout.LabelField(entry == null ? "Original sampled curve (unchanged)" : "Unity curve override for this particle resource", EditorStyles.wordWrappedMiniLabel); + if (entry != null && GUILayout.Button("Restore original particle curve")) + { + Undo.RecordObject(_edits, "Restore particle curve"); + _edits.ParticleCurves.Remove(entry); + MarkChanged(); + float time = _time; + Restart(); + Seek(time); + } + } + + private static AnimationCurve CopyCurve(AnimationCurve curve) => + new(curve.keys) + { + preWrapMode = curve.preWrapMode, + postWrapMode = curve.postWrapMode + }; + + private static AnimationCurve SampleCurve(ParticleCurve source, int channel) + { + var curve = new AnimationCurve(Enumerable.Range(0, 101).Select(i => new Keyframe(i / 100f, source.Evaluate(i / 100f, channel))).ToArray()); + for (int i = 0; i < curve.length; i++) + { + AnimationUtility.SetKeyLeftTangentMode(curve, i, AnimationUtility.TangentMode.Linear); + AnimationUtility.SetKeyRightTangentMode(curve, i, AnimationUtility.TangentMode.Linear); + } + return curve; + } + } +} diff --git a/Assets/Scripts/Editor/EffectEditor.cs.meta b/Assets/Scripts/Editor/EffectEditor.cs.meta new file mode 100644 index 000000000..b735c8759 --- /dev/null +++ b/Assets/Scripts/Editor/EffectEditor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 28e20becd27fe4cc3b3151897935cd30 \ No newline at end of file diff --git a/Assets/Scripts/Editor/PrivateAssetBuildGuard.cs b/Assets/Scripts/Editor/PrivateAssetBuildGuard.cs deleted file mode 100644 index 7d69698e7..000000000 --- a/Assets/Scripts/Editor/PrivateAssetBuildGuard.cs +++ /dev/null @@ -1,61 +0,0 @@ -// ------------------------------------------------------------------------ -// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. -// See LICENSE file in the project root for license information. -// ------------------------------------------------------------------------ - -namespace Editor -{ - using System; - using System.Linq; - using UnityEditor; - using UnityEditor.Build; - using UnityEditor.Build.Reporting; - - internal sealed class PrivateAssetBuildGuard : IPreprocessBuildWithReport - { - private const string PrivateBuildVariable = "PAL3_ALLOW_PRIVATE_ASSETS"; - private const string StoryVfxPath = "/Resources/Prefabs/VFX/"; - private const string HovlStudioPath = "/Hovl Studio/"; - private const string RealToonPath = "/RealToon/"; - private const string LegacyToonMaterialsPath = "Assets/Resources/Materials/"; - private const string Pal3VfxReadme = "Assets/Resources/Prefabs/VFX/PAL3/README.txt"; - private const string Pal3AVfxReadme = "Assets/Resources/Prefabs/VFX/PAL3A/README.txt"; - - internal static bool PrivateBuildEnabled => - Environment.GetEnvironmentVariable(PrivateBuildVariable) == "1" && - string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) && - string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")); - - public int callbackOrder => int.MaxValue; - - public void OnPreprocessBuild(BuildReport report) => Validate(); - - internal static void Validate() - { - if (PrivateBuildEnabled) return; - - string privateAsset = AssetDatabase.GetAllAssetPaths().FirstOrDefault(IsPrivateAsset); - if (privateAsset == null) return; - - throw new BuildFailedException( - $"Local optional VFX or legacy Toon assets are installed ({privateAsset}). " + - "Use a clean checkout for public builds. " + - $"For a private local build, launch Unity with {PrivateBuildVariable}=1."); - } - - private static bool IsPrivateAsset(string assetPath) - { - if (assetPath.IndexOf(HovlStudioPath, StringComparison.OrdinalIgnoreCase) < 0 && - assetPath.IndexOf(RealToonPath, StringComparison.OrdinalIgnoreCase) < 0 && - !assetPath.StartsWith(LegacyToonMaterialsPath, StringComparison.OrdinalIgnoreCase) && - assetPath.IndexOf(StoryVfxPath, StringComparison.OrdinalIgnoreCase) < 0) - { - return false; - } - - return !assetPath.Equals(Pal3VfxReadme, StringComparison.OrdinalIgnoreCase) && - !assetPath.Equals(Pal3AVfxReadme, StringComparison.OrdinalIgnoreCase) && - !AssetDatabase.IsValidFolder(assetPath); - } - } -} diff --git a/Assets/Scripts/Editor/PrivateAssetBuildGuard.cs.meta b/Assets/Scripts/Editor/PrivateAssetBuildGuard.cs.meta deleted file mode 100644 index d47c922a7..000000000 --- a/Assets/Scripts/Editor/PrivateAssetBuildGuard.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: ac41b9b3ac5641d98126273f7efb452f diff --git a/Assets/Scripts/Editor/ReleaseBuildPipeline.cs b/Assets/Scripts/Editor/ReleaseBuildPipeline.cs index fe9b62a72..64531ccc8 100644 --- a/Assets/Scripts/Editor/ReleaseBuildPipeline.cs +++ b/Assets/Scripts/Editor/ReleaseBuildPipeline.cs @@ -131,15 +131,13 @@ public static void ZipAll() private static void BuildGame(Pal3BuildTarget buildTarget) { - PrivateAssetBuildGuard.Validate(); - BuildTargetGroup targetGroupBeforeBuild = EditorUserBuildSettings.selectedBuildTargetGroup; BuildTarget targetBeforeBuild = EditorUserBuildSettings.activeBuildTarget; string buildOutputPath = EditorUtility.SaveFolderPanel("Choose Location of Built Game", "", ""); if (string.IsNullOrWhiteSpace(buildOutputPath)) return; - string versionFolder = GetVersionFolderName(PrivateAssetBuildGuard.PrivateBuildEnabled); + string versionFolder = GetVersionFolderName(); buildOutputPath += $"{DirSeparator}{versionFolder}{DirSeparator}"; var buildConfigurations = new[] @@ -271,14 +269,14 @@ private static void WriteBuildReport(BuildReport report, List logActions } } - private static string GetVersionFolderName(bool privateBuild) + private static string GetVersionFolderName() { - return PlayerSettings.bundleVersion + (privateBuild ? "-Private" : ""); + return PlayerSettings.bundleVersion; } private static void ZipReleaseFiles() { - string versionFolder = GetVersionFolderName(PrivateAssetBuildGuard.PrivateBuildEnabled); + string versionFolder = GetVersionFolderName(); string buildOutputPath = EditorUtility.SaveFolderPanel("Choose Location of Built Game", versionFolder, versionFolder); if (string.IsNullOrWhiteSpace(buildOutputPath)) return; @@ -289,12 +287,10 @@ private static void ZipReleaseFiles(string buildOutputPath) { string selectedFolder = Path.GetFileName(buildOutputPath.TrimEnd( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); - bool privateBuild = selectedFolder == GetVersionFolderName(true); - if (!privateBuild && selectedFolder != GetVersionFolderName(false)) + if (selectedFolder != GetVersionFolderName()) { Debug.LogError($"Invalid build output path: {buildOutputPath}. " + - $"Please choose the current release build folder \"{GetVersionFolderName(false)}\" " + - $"or \"{GetVersionFolderName(true)}\"."); + $"Please choose the current release build folder \"{GetVersionFolderName()}\"."); return; } @@ -313,7 +309,7 @@ private static void ZipReleaseFiles(string buildOutputPath) foreach ((string folderPath, Pal3BuildTarget target) in buildTargets) { string fullPath = $"{buildOutputPath}{DirSeparator}{folderPath}"; - string zipFileName = $"{GameConstants.AppName}_v{GetVersionFolderName(privateBuild)}_{target.ToString()}.zip"; + string zipFileName = $"{GameConstants.AppName}_v{GetVersionFolderName()}_{target.ToString()}.zip"; string zipFilePath = $"{releaseDirPath}{DirSeparator}{zipFileName}"; if (File.Exists(fullPath) || Directory.Exists(fullPath)) diff --git a/Assets/Scripts/Engine/Extensions/UnityObjectExtensions.cs b/Assets/Scripts/Engine/Extensions/UnityObjectExtensions.cs index 01373efea..a0e92e549 100644 --- a/Assets/Scripts/Engine/Extensions/UnityObjectExtensions.cs +++ b/Assets/Scripts/Engine/Extensions/UnityObjectExtensions.cs @@ -11,8 +11,16 @@ public static void Destroy(this UnityEngine.Object obj) { if (obj != null) { +#if UNITY_EDITOR + // Model renderers are also used in isolated editor preview scenes. + if (!UnityEngine.Application.isPlaying) + { + UnityEngine.Object.DestroyImmediate(obj); + return; + } +#endif UnityEngine.Object.Destroy(obj); } } } -} \ No newline at end of file +} diff --git a/Assets/Scripts/Pal3.Core/Contract/Constants/EffectConstants.cs b/Assets/Scripts/Pal3.Core/Contract/Constants/EffectConstants.cs index dc9bdc996..a2b11ccdb 100644 --- a/Assets/Scripts/Pal3.Core/Contract/Constants/EffectConstants.cs +++ b/Assets/Scripts/Pal3.Core/Contract/Constants/EffectConstants.cs @@ -61,65 +61,5 @@ public static class EffectConstants "", 1f, 0.6f) }, }; #endif - - #if PAL3 - // Effect group id to sfx mapping - public static readonly Dictionary EffectSfxInfo = new() - { - { 251, "wd163" }, - { 252, "wd160" }, - { 254, "wd163" }, - { 255, "wd155" }, - { 256, "wd008" }, - { 257, "wd001" }, - { 261, "wd163" }, - { 264, "wd160" }, - { 266, "wd160" }, - { 276, "wd150" }, - { 278, "wd150" }, - { 297, "wd141" }, - { 341, "wd160" }, - { 457, "wd141" }, - { 458, "wd140" }, - }; - #elif PAL3A - public static readonly Dictionary EffectSfxInfo = new() - { - { 91, "WD156" }, - { 170, "WD155" }, - { 171, "WD163" }, - { 172, "WD189" }, - { 173, "WD189" }, - { 174, "WD308" }, - { 175, "WD051" }, - { 176, "WD051" }, - { 177, "WD159" }, - { 178, "WD032" }, - { 179, "WD497" }, - { 181, "WD018" }, - { 182, "WD018" }, - { 189, "WD035" }, - { 198, "WD018" }, - { 209, "WD309" }, - { 211, "WD002" }, - { 230, "WD342" }, - { 254, "WD163" }, - { 257, "WD008" }, - { 258, "WD165" }, - { 259, "WD163" }, - { 281, "WD159" }, - { 283, "WD018" }, - { 285, "WD422" }, - { 290, "WD418" }, - { 299, "WD150" }, - { 322, "WD163" }, - { 323, "WD189" }, - { 328, "WD532" }, - { 388, "WD165" }, - { 389, "WD165" }, - { 397, "WD459" }, - { 408, "WD459" }, - }; - #endif } } \ No newline at end of file diff --git a/Assets/Scripts/Pal3.Core/DataReader/Cpk/Lzo/MiniLzo.cs b/Assets/Scripts/Pal3.Core/DataReader/Cpk/Lzo/MiniLzo.cs index 341749967..55f8abd9f 100644 --- a/Assets/Scripts/Pal3.Core/DataReader/Cpk/Lzo/MiniLzo.cs +++ b/Assets/Scripts/Pal3.Core/DataReader/Cpk/Lzo/MiniLzo.cs @@ -7,7 +7,6 @@ * @note Uses unsafe/fixed pointer contexts internally * @liscence Bound by same liscence as minilzo as below, see file COPYING */ - /* Based on minilzo.c -- mini subset of the LZO real-time data compression library This file is part of the LZO real-time data compression library. @@ -58,9 +57,16 @@ public static class MiniLzo { private const uint M2_MAX_OFFSET = 0x0800; - public static unsafe void Decompress(Span src, byte[] dst) + public static void Decompress(Span src, byte[] dst) => DecompressInternal(src, dst, false); + + // Some PAL3 flexeff files retain a complete LZO stream followed by unused + // bytes in a fixed-size file. Keep ordinary CPK decompression strict. + public static int DecompressPrefix(Span src, byte[] dst) => DecompressInternal(src, dst, true); + + private static unsafe int DecompressInternal(Span src, byte[] dst, bool allowTrailingBytes) { - if (src.Length < 3) throw new OverflowException("Input Overrun"); + if (src.Length < 3) + throw new OverflowException("Input Overrun"); uint t = 0; fixed (byte* input = src, output = dst) { @@ -276,7 +282,7 @@ public static unsafe void Decompress(Span src, byte[] dst) } while (--t > 0); } } - else if(!eof_found && !match_done) + else if (!eof_found && !match_done) { copy_match = false; @@ -320,13 +326,15 @@ public static unsafe void Decompress(Span src, byte[] dst) throw new OverflowException("EOF Marker Not Found"); else { - if (op != op_end) throw new OverflowException("Output Length Mismatch"); + if (op != op_end) + throw new OverflowException("Output Length Mismatch"); //Debug.Assert(t == 1); if (ip > ip_end) throw new OverflowException("Input Overrun"); - else if (ip < ip_end) + else if (ip < ip_end && !allowTrailingBytes) throw new OverflowException("Input Not Consumed"); } + return checked((int)(ip - input)); } } } diff --git a/Assets/Scripts/Pal3.Core/DataReader/Data/EffectDefinitionFile.cs b/Assets/Scripts/Pal3.Core/DataReader/Data/EffectDefinitionFile.cs index b993d5f22..9e6eca770 100644 --- a/Assets/Scripts/Pal3.Core/DataReader/Data/EffectDefinitionFile.cs +++ b/Assets/Scripts/Pal3.Core/DataReader/Data/EffectDefinitionFile.cs @@ -8,6 +8,7 @@ namespace Pal3.Core.DataReader.Data //using System.Runtime.InteropServices; //[StructLayout(LayoutKind.Explicit)] + [System.Serializable] public struct EffectDefinition { //[FieldOffset(0)] @@ -55,7 +56,7 @@ public struct EffectDefinition public float InitAngle; //[FieldOffset(200)] - public int InitSize; + public float InitSize; //[FieldOffset(204)] public uint InitColor; @@ -124,11 +125,27 @@ public struct EffectDefinition public sealed class EffectDefinitionFile { - public EffectDefinitionFile(EffectDefinition[] effectDefinitions) + private readonly System.Collections.Generic.Dictionary _types = new(); + + public EffectDefinitionFile(EffectDefinition[] effectDefinitions, EffectGroup[] groups = null) { EffectDefinitions = effectDefinitions; + Groups = groups ?? System.Array.Empty(); + foreach (EffectDefinition definition in effectDefinitions) + if (definition.IsValid && !_types.ContainsKey(definition.TypeNameCrc)) + _types.Add(definition.TypeNameCrc, definition); + } + + public EffectDefinition[] EffectDefinitions + { + get; + } + + public EffectGroup[] Groups + { + get; } - public EffectDefinition[] EffectDefinitions { get; } + public bool TryGetType(uint crc, out EffectDefinition definition) => _types.TryGetValue(crc, out definition); } -} \ No newline at end of file +} diff --git a/Assets/Scripts/Pal3.Core/DataReader/Data/EffectDefinitionFileReader.cs b/Assets/Scripts/Pal3.Core/DataReader/Data/EffectDefinitionFileReader.cs index 87a008f1b..a955264d9 100644 --- a/Assets/Scripts/Pal3.Core/DataReader/Data/EffectDefinitionFileReader.cs +++ b/Assets/Scripts/Pal3.Core/DataReader/Data/EffectDefinitionFileReader.cs @@ -5,22 +5,142 @@ namespace Pal3.Core.DataReader.Data { + using System; + public sealed class EffectDefinitionFileReader : IFileReader { - private const int NUM_OF_EFFECTS = 1324; + public const int Pal3Size = 1324 * 280 + 560 * 15940; + public const int Pal3ASize = 2048 * 280 + 1024 * 15940; public EffectDefinitionFile Read(IBinaryReader reader, int codepage) { - EffectDefinition[] effectDefinitions = new EffectDefinition[NUM_OF_EFFECTS]; + byte[] bytes = reader.ReadExactly(checked((int)(reader.Length - reader.Position))); + // Validate the layout before accepting an unwrapped file. The shipped + // PAL3 sample is a padded LZO stream with the same length as the table. + if (bytes.Length == Pal3Size || bytes.Length == Pal3ASize) + { + try + { + return ReadPayload(bytes, codepage); + } + catch (System.IO.InvalidDataException) + { + + } + var decoded = new byte[bytes.Length]; + Cpk.Lzo.MiniLzo.DecompressPrefix(bytes, decoded); + return ReadPayload(decoded, codepage); + } + if (bytes.Length >= 7) + { + int size = bytes[0] | bytes[1] << 8 | bytes[2] << 16 | bytes[3] << 24; + if (size == Pal3Size || size == Pal3ASize) + { + var decoded = new byte[size]; + Cpk.Lzo.MiniLzo.Decompress(bytes.AsSpan(4), decoded); + return ReadPayload(decoded, codepage); + } + } + throw new System.IO.InvalidDataException("Unrecognized flexeff table layout or compression wrapper"); + } - for (int i = 0; i < NUM_OF_EFFECTS; i++) + private EffectDefinitionFile ReadPayload(byte[] bytes, int codepage) + { + int typeCount = bytes.Length == Pal3Size ? 1324 : 2048; + int groupCount = bytes.Length == Pal3Size ? 560 : 1024; + using IBinaryReader reader = new SafeBinaryReader(bytes); + var definitions = new EffectDefinition[typeCount]; + for (int i = 0; i < typeCount; i++) { - effectDefinitions[i] = ReadEffectDefinition(reader, codepage); + // Invalid slots may contain uninitialized native bytes. + long offset = reader.Position; + reader.Seek(offset + 212, System.IO.SeekOrigin.Begin); + byte valid = reader.ReadByte(); + if (valid > 1) + throw new System.IO.InvalidDataException($"Invalid effect type flag at {i}"); + reader.Seek(offset, System.IO.SeekOrigin.Begin); + definitions[i] = ReadEffectDefinition(reader, codepage); + if (valid == 1 && (float.IsNaN(definitions[i].InitSize) || float.IsInfinity(definitions[i].InitSize))) + throw new System.IO.InvalidDataException($"Invalid effect size at {i}"); } + var groups = new EffectGroup[groupCount]; + for (int g = 0; g < groupCount; g++) + { + int count = reader.ReadInt32(); + if (count < 0 || count > 16) + throw new System.IO.InvalidDataException($"Invalid effect group size at {g}"); + uint[] crcs = reader.ReadUInt32s(16); + var objects = new EffectGroupObject[count]; + for (int o = 0; o < 16; o++) + { + var tasks = new System.Collections.Generic.List(); + for (int t = 0; t < 8; t++) + { + if (o >= count) + { + reader.Seek(124, System.IO.SeekOrigin.Current); + continue; + } + EffectTask task = ReadTask(reader); + if (task.IsValid) + tasks.Add(task); + } + if (o < count) + objects[o] = new EffectGroupObject { TypeCrc = crcs[o], Tasks = tasks.ToArray() }; + } + groups[g] = new EffectGroup { Objects = objects }; + } + var file = new EffectDefinitionFile(definitions, groups); + for (int g = 0; g < groups.Length; g++) + foreach (EffectGroupObject obj in groups[g].Objects) + if (!file.TryGetType(obj.TypeCrc, out _)) + throw new System.IO.InvalidDataException($"Group {g} references missing effect type {obj.TypeCrc:X8}"); + return file; + } - return new EffectDefinitionFile(effectDefinitions); + private static EffectTask ReadTask(IBinaryReader reader) + { + var task = new EffectTask + { + XStretchSpeed = reader.ReadSingle(), + SpeedX = reader.ReadSingle(), + SpeedY = reader.ReadSingle(), + SpeedZ = reader.ReadSingle(), + SpeedRot = reader.ReadSingle(), + CircleAxisX = reader.ReadSingle(), + CircleAxisY = reader.ReadSingle(), + CircleAxisZ = reader.ReadSingle(), + CircleRadius = reader.ReadSingle(), + CircleSpeed = reader.ReadSingle(), + RotAxisX = reader.ReadSingle(), + RotAxisY = reader.ReadSingle(), + RotAxisZ = reader.ReadSingle(), + RotSpeed = reader.ReadSingle(), + SpeedAlpha = reader.ReadSingle(), + SpeedRed = reader.ReadSingle(), + SpeedGreen = reader.ReadSingle(), + SpeedBlue = reader.ReadSingle(), + StartTime = reader.ReadSingle(), + Duration = reader.ReadSingle(), + RequireLoops = reader.ReadInt32(), + CreateTimeSlot = reader.ReadSingle(), + }; + byte valid = reader.ReadByte(); + if (valid > 1) + throw new System.IO.InvalidDataException("Invalid task validity flag"); + task.IsValid = valid == 1; + task.CreateNew = reader.ReadBoolean(); + task.ModifyLast = reader.ReadBoolean(); + reader.ReadByte(); // native 4-byte alignment + task.YStretchSpeed = reader.ReadSingle(); + reader.Seek(28, System.IO.SeekOrigin.Current); // reserved, not active parameters + if (task.IsValid && (!Finite(task.StartTime) || !Finite(task.Duration) || task.Duration < 0)) + throw new System.IO.InvalidDataException("Invalid effect task timing"); + return task; } + private static bool Finite(float value) => !float.IsNaN(value) && !float.IsInfinity(value); + private EffectDefinition ReadEffectDefinition(IBinaryReader reader, int codepage) { EffectDefinition effectDefinition = new() @@ -55,7 +175,7 @@ private EffectDefinition ReadEffectDefinition(IBinaryReader reader, int codepage effectDefinition.InitAngle = unionFloat; } - effectDefinition.InitSize = reader.ReadInt32(); + effectDefinition.InitSize = reader.ReadSingle(); effectDefinition.InitColor = reader.ReadUInt32(); effectDefinition.MaxQuadCount = reader.ReadInt32(); @@ -90,4 +210,4 @@ private EffectDefinition ReadEffectDefinition(IBinaryReader reader, int codepage return effectDefinition; } } -} \ No newline at end of file +} diff --git a/Assets/Scripts/Pal3.Core/DataReader/Data/EffectGroup.cs b/Assets/Scripts/Pal3.Core/DataReader/Data/EffectGroup.cs new file mode 100644 index 000000000..56b69739e --- /dev/null +++ b/Assets/Scripts/Pal3.Core/DataReader/Data/EffectGroup.cs @@ -0,0 +1,85 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Pal3.Core.DataReader.Data +{ + using System; + using System.Collections.Generic; + using Cpk; + using Primitives; + + // The names describe the quad interpretation of the original unions. + // Callback/audio/model adapters interpret these same fields by class. + [Serializable] + public sealed class EffectTask + { + public float XStretchSpeed, SpeedX, SpeedY, SpeedZ, SpeedRot; + public float CircleAxisX, CircleAxisY, CircleAxisZ, CircleRadius, CircleSpeed; + public float RotAxisX, RotAxisY, RotAxisZ, RotSpeed; + public float SpeedAlpha, SpeedRed, SpeedGreen, SpeedBlue, StartTime, Duration; + public int RequireLoops; + public float CreateTimeSlot; + public bool IsValid, CreateNew, ModifyLast; + public float YStretchSpeed; + + public GameBoxVector3 Speed => new(SpeedX, SpeedY, SpeedZ); + public GameBoxVector3 CircleAxis => new(CircleAxisX, CircleAxisY, CircleAxisZ); + + public IEnumerable<(int groupId, GameBoxVector3 position)> Callbacks() + { + if (XStretchSpeed != 0) + yield return ((int)MathF.Round(XStretchSpeed), new(SpeedX, SpeedY, SpeedZ)); + if (CircleRadius != 0) + yield return ((int)MathF.Round(CircleRadius), new(RotAxisX, RotAxisY, RotAxisZ)); + if (CircleAxisX != 0) + yield return ((int)MathF.Round(CircleAxisX), new(CircleAxisY, CircleAxisZ, CircleSpeed)); + if (SpeedAlpha != 0) + yield return ((int)MathF.Round(SpeedAlpha), new(SpeedRed, SpeedGreen, SpeedBlue)); + } + + public IEnumerable<(int index, float delay)> Sounds() + { + if (XStretchSpeed != 0) + yield return ((int)MathF.Round(XStretchSpeed), SpeedX); + if (SpeedY != 0) + yield return ((int)MathF.Round(SpeedY), SpeedZ); + if (CircleRadius != 0) + yield return ((int)MathF.Round(CircleRadius), RotAxisX); + if (RotAxisY != 0) + yield return ((int)MathF.Round(RotAxisY), RotAxisZ); + } + } + + [Serializable] + public sealed class EffectGroupObject + { + public uint TypeCrc; + public EffectTask[] Tasks = Array.Empty(); + } + + [Serializable] + public sealed class EffectGroup + { + public EffectGroupObject[] Objects = Array.Empty(); + } + + public static class EffectClass + { + private static readonly Dictionary Names = CreateNames(); + + private static Dictionary CreateNames() + { + var hash = new Crc32Hash(); + hash.Init(); + var result = new Dictionary(); + foreach (string name in new[] { "quad", "particle", "cylinder", "model", "callback", "audio", + "meteor", "shock", "grid", "explosive", "action", "camera", "time", "bink", "quake", "S-Particle" }) + result.Add(hash.Compute(name, 936), name); + return result; + } + + public static string Name(uint crc) => Names.TryGetValue(crc, out string name) ? name : $"unknown:{crc:X8}"; + } +} diff --git a/Assets/Scripts/Pal3.Core/DataReader/Data/EffectGroup.cs.meta b/Assets/Scripts/Pal3.Core/DataReader/Data/EffectGroup.cs.meta new file mode 100644 index 000000000..9667975de --- /dev/null +++ b/Assets/Scripts/Pal3.Core/DataReader/Data/EffectGroup.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9fd0064acbd0242f981e846ddb645825 \ No newline at end of file diff --git a/Assets/Scripts/Pal3.Core/DataReader/Par.meta b/Assets/Scripts/Pal3.Core/DataReader/Par.meta new file mode 100644 index 000000000..ed35cceb5 --- /dev/null +++ b/Assets/Scripts/Pal3.Core/DataReader/Par.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c2f352a62352f4c2b8747dd5dd7168ae +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Pal3.Core/DataReader/Par/ParticleFile.cs b/Assets/Scripts/Pal3.Core/DataReader/Par/ParticleFile.cs new file mode 100644 index 000000000..475ec3b2e --- /dev/null +++ b/Assets/Scripts/Pal3.Core/DataReader/Par/ParticleFile.cs @@ -0,0 +1,88 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Pal3.Core.DataReader.Par +{ + using System; + using Primitives; + + public sealed class ParticleCurve + { + public int Channels; + public string Name; + public GameBoxVector3[][] Samples; + + public Func OverrideEvaluation + { + get; set; + } + + public float Evaluate(float time, int channel = 0) + { + if (OverrideEvaluation != null) + return OverrideEvaluation(time, channel); + if (channel < 0 || channel >= Channels) + return 0; + int lo = 0, hi = 100; + for (int i = 0; i < 5; i++) + { + int mid = (hi + lo) / 2; + if (Samples[0][mid].X <= time) + lo = mid; + else + hi = mid; + } + for (int i = lo; i < hi; i++) + { + if (Samples[0][i].X > time || Samples[0][i + 1].X < time) + continue; + GameBoxVector3 a = Samples[channel][i], b = Samples[channel][i + 1]; + return b.X == a.X ? a.Y : a.Y + (b.Y - a.Y) * ((time - a.X) / (b.X - a.X)); + } + return 0; + } + } + + public sealed class ParticleDescription + { + public string Name, Texture; + public float Gravity, NoResistanceSpeed, Resistance; + public int VelocityResistancePower, SizeResistancePower, TextureSize, AnimationType, Fps; + public bool RandomFirstFrame, RandomRotationDirection, MirrorX, MirrorY; + public int RotationStart, RotationEnd, Rows, Columns, BlendMode; + public ParticleCurve Size, Rotation, Alpha, Color; + } + + public sealed class ParticleEmitter + { + public string Name, ShapePath; + public GameBoxVector3 Position, Scope, BoxMin, BoxMax; + public int Shape; + public float SpeedMultiplier, CycleMin, CycleMax, Hotspot, Falloff; + public bool Loop; + public ParticleCurve Direction, RandomVelocity, Emission, Life, Size, Rotation; + } + + public sealed class ParticleController + { + public string Name; + public int ParticleIndex, EmitterIndex, Capacity; + } + + public sealed class ParticleFile + { + public uint Version; + public int Capacity, Space, Overflow; + public float Scale; + public ParticleDescription[] Particles; + public ParticleEmitter[] Emitters; + public ParticleController[] Controllers; + } + + public sealed class ParticleShapeFile + { + public GameBoxVector3[] Points; + } +} diff --git a/Assets/Scripts/Pal3.Core/DataReader/Par/ParticleFile.cs.meta b/Assets/Scripts/Pal3.Core/DataReader/Par/ParticleFile.cs.meta new file mode 100644 index 000000000..38cc07807 --- /dev/null +++ b/Assets/Scripts/Pal3.Core/DataReader/Par/ParticleFile.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 30b0fbf09f821494eb895c502aa5cdcb \ No newline at end of file diff --git a/Assets/Scripts/Pal3.Core/DataReader/Par/ParticleFileReader.cs b/Assets/Scripts/Pal3.Core/DataReader/Par/ParticleFileReader.cs new file mode 100644 index 000000000..59ef21589 --- /dev/null +++ b/Assets/Scripts/Pal3.Core/DataReader/Par/ParticleFileReader.cs @@ -0,0 +1,160 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Pal3.Core.DataReader.Par +{ + using System; + using System.IO; + + public sealed class ParticleFileReader : IFileReader + { + public ParticleFile Read(IBinaryReader r, int codepage) + { + if (r.ReadString(6) != "sgpar\0") + throw new InvalidDataException("Not an sgpar particle file (SPar is not supported)"); + uint version = r.ReadUInt32(); + if (version != 0x10000 && version != 0x10001) + throw new InvalidDataException($"Unsupported sgpar version {version:X}"); + var file = new ParticleFile + { + Version = version, + Capacity = Count(r, 65536), + Space = r.ReadInt32(), + Overflow = r.ReadInt32(), + Scale = version > 0x10000 ? r.ReadSingle() : 1f + }; + if (file.Space < 0 || file.Space > 1 || file.Overflow < 0 || file.Overflow > 2 || !Finite(file.Scale) || file.Scale <= 0) + throw new InvalidDataException("Invalid sgpar scene parameters"); + int np = Count(r, 64), ne = Count(r, 64), nc = Count(r, 64); + file.Particles = new ParticleDescription[np]; + file.Emitters = new ParticleEmitter[ne]; + file.Controllers = new ParticleController[nc]; + for (int i = 0; i < np; i++) + file.Particles[i] = ReadParticle(r, codepage); + for (int i = 0; i < ne; i++) + file.Emitters[i] = ReadEmitter(r, codepage, version); + for (int i = 0; i < nc; i++) + { + var c = new ParticleController + { + Name = r.ReadString(64, codepage), + ParticleIndex = r.ReadInt32(), + EmitterIndex = r.ReadInt32(), + Capacity = Count(r, 65536) + }; + if (c.ParticleIndex < 0 || c.ParticleIndex >= np || c.EmitterIndex < 0 || c.EmitterIndex >= ne) + throw new InvalidDataException("Invalid sgpar controller reference"); + file.Controllers[i] = c; + } + if (r.Position != r.Length) + throw new InvalidDataException("Unexpected sgpar trailing data"); + return file; + } + + private static ParticleDescription ReadParticle(IBinaryReader r, int cp) + { + var p = new ParticleDescription { Name = r.ReadString(32, cp) }; + r.ReadInt32(); // native BOOL lighting (unused by original renderer) + p.Gravity = r.ReadSingle(); + p.NoResistanceSpeed = r.ReadSingle(); + p.VelocityResistancePower = r.ReadInt32(); + p.SizeResistancePower = r.ReadInt32(); + p.Resistance = r.ReadSingle(); + p.Texture = r.ReadString(256, cp); + p.TextureSize = r.ReadInt32(); + p.AnimationType = r.ReadInt32(); + p.Fps = r.ReadInt32(); + p.RandomFirstFrame = r.ReadInt32() != 0; + p.RotationStart = r.ReadInt32(); + p.RotationEnd = r.ReadInt32(); + p.RandomRotationDirection = r.ReadInt32() != 0; + p.MirrorX = r.ReadInt32() != 0; + p.MirrorY = r.ReadInt32() != 0; + p.Rows = r.ReadInt32(); + p.Columns = r.ReadInt32(); + p.BlendMode = r.ReadInt32(); + if (p.Rows < 1 || p.Columns < 1 || p.Rows > 256 || p.Columns > 256) + throw new InvalidDataException("Invalid particle atlas"); + p.Size = ReadCurve(r, cp); + p.Rotation = ReadCurve(r, cp); + p.Alpha = ReadCurve(r, cp); + p.Color = ReadCurve(r, cp); + return p; + } + + private static ParticleEmitter ReadEmitter(IBinaryReader r, int cp, uint version) + { + var e = new ParticleEmitter { Name = r.ReadString(64, cp), Position = r.ReadGameBoxVector3(), Scope = r.ReadGameBoxVector3(), Shape = r.ReadInt32() }; + e.ShapePath = version > 0x10000 ? r.ReadString(64, cp) : ""; + e.SpeedMultiplier = version > 0x10000 ? r.ReadSingle() : 1f; + e.CycleMin = r.ReadSingle(); + e.CycleMax = r.ReadSingle(); + e.Loop = r.ReadInt32() != 0; + r.ReadInt32(); // lighton + e.Hotspot = r.ReadSingle(); + e.Falloff = r.ReadSingle(); + e.BoxMin = r.ReadGameBoxVector3(); + e.BoxMax = r.ReadGameBoxVector3(); + if (e.Shape < 0 || e.Shape > 2) + throw new InvalidDataException("Invalid sgpar emitter shape"); + e.Direction = ReadCurve(r, cp); + e.RandomVelocity = ReadCurve(r, cp); + e.Emission = ReadCurve(r, cp); + e.Life = ReadCurve(r, cp); + e.Size = ReadCurve(r, cp); + e.Rotation = ReadCurve(r, cp); + return e; + } + + private static ParticleCurve ReadCurve(IBinaryReader r, int cp) + { + int channels = Count(r, 3), points = Count(r, 20); + var curve = new ParticleCurve { Channels = channels, Name = r.ReadString(64, cp), Samples = new Primitives.GameBoxVector3[3][] }; + r.ReadString(128, cp); + r.ReadGameBoxVector3(); + r.ReadGameBoxVector3(); + r.Seek(2 * 3 * 20 * 12, SeekOrigin.Current); // editor and real control points; runtime uses stored samples + for (int c = 0; c < 3; c++) + curve.Samples[c] = r.ReadGameBoxVector3s(101); + for (int c = 0; c < channels; c++) + for (int i = 0; i < 101; i++) + { + var p = curve.Samples[c][i]; + // Shipped curves can double back in x. Preserve their original + // sample order and lookup semantics instead of sorting them. + if (!Finite(p.X) || !Finite(p.Y)) + throw new InvalidDataException($"Invalid particle curve {curve.Name} channel={c} sample={i} x={p.X} y={p.Y} previous={(i > 0 ? curve.Samples[c][i - 1].X : 0)}"); + } + return curve; + } + + private static int Count(IBinaryReader r, int max) + { + int value = r.ReadInt32(); + if (value < 0 || value > max) + throw new InvalidDataException("Invalid sgpar count"); + return value; + } + + private static bool Finite(float v) => !float.IsNaN(v) && !float.IsInfinity(v); + } + + public sealed class ParticleShapeFileReader : IFileReader + { + public ParticleShapeFile Read(IBinaryReader r, int codepage) + { + if (r.ReadString(4) != "csf\0") + throw new InvalidDataException("Invalid CSF header"); + r.ReadUInt32(); + int count = r.ReadInt32(); + if (count < 2 || count > 1000000 || count > (r.Length - r.Position) / 12) + throw new InvalidDataException("Invalid CSF point count"); + var result = new ParticleShapeFile { Points = r.ReadGameBoxVector3s(count) }; + if (r.Position != r.Length) + throw new InvalidDataException("Unexpected CSF trailing data"); + return result; + } + } +} diff --git a/Assets/Scripts/Pal3.Core/DataReader/Par/ParticleFileReader.cs.meta b/Assets/Scripts/Pal3.Core/DataReader/Par/ParticleFileReader.cs.meta new file mode 100644 index 000000000..a05265f77 --- /dev/null +++ b/Assets/Scripts/Pal3.Core/DataReader/Par/ParticleFileReader.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0c0f89a3d0f3c4df9864b29cbc004105 \ No newline at end of file diff --git a/Assets/Scripts/Pal3.Core/DataReader/Par/SparFile.cs b/Assets/Scripts/Pal3.Core/DataReader/Par/SparFile.cs new file mode 100644 index 000000000..282bf0061 --- /dev/null +++ b/Assets/Scripts/Pal3.Core/DataReader/Par/SparFile.cs @@ -0,0 +1,60 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Pal3.Core.DataReader.Par +{ + using System; + using Primitives; + + [Serializable] + public sealed class SparController + { + public int Kind; + public float[] Parameters; + } + + [Serializable] + public sealed class SparKeyframe + { + public float Time; + public GameBoxVector3 Position; + public int EmissionMinimum, EmissionMaximum, Shape; + public float[] ShapeParameters; + public bool HasPath; + public float PathRate; + public GameBoxVector3[] PathPoints; + public GameBoxVector3 VelocityMinimum, VelocityMaximum; + public float RotationMinimum, RotationMaximum, SpinMinimum, SpinMaximum; + public GameBoxVector3 ColorMinimum, ColorMaximum; + public float AlphaMinimum, AlphaMaximum, LifeMinimum, LifeMaximum, SizeMinimum, SizeMaximum; + public int FrameMinimum, FrameMaximum; + public SparController[] Controllers; + public float[] Reserved; + } + + [Serializable] + public sealed class SparGroup + { + public string Name, Texture; + public int Capacity, VertexCapacity, Mode; + public bool Loop; + // Rebuilt by the binary reader; jagged arrays are not Unity-serializable. + [NonSerialized] + public float[][] TextureFrames; + public SparKeyframe[] Keyframes; + } + + [Serializable] + public sealed class SparFile + { + public float Version; + public SparGroup[] Groups; + public int Shader; + public float[] ShaderParameters; + // Some exported files contain an editor tail. The original loader closes + // the file after the shader parameters and never consumes this tail. + public int UnusedByteCount; + } +} diff --git a/Assets/Scripts/Pal3.Core/DataReader/Par/SparFile.cs.meta b/Assets/Scripts/Pal3.Core/DataReader/Par/SparFile.cs.meta new file mode 100644 index 000000000..5c270f282 --- /dev/null +++ b/Assets/Scripts/Pal3.Core/DataReader/Par/SparFile.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f413782ba81242aea2d6d1c011781e39 diff --git a/Assets/Scripts/Pal3.Core/DataReader/Par/SparFileReader.cs b/Assets/Scripts/Pal3.Core/DataReader/Par/SparFileReader.cs new file mode 100644 index 000000000..968e2ab4f --- /dev/null +++ b/Assets/Scripts/Pal3.Core/DataReader/Par/SparFileReader.cs @@ -0,0 +1,132 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Pal3.Core.DataReader.Par +{ + using System; + using System.IO; + + // PAL3A.exe loader 0x4c65a3. This is independent of the sgpar format. + public sealed class SparFileReader : IFileReader + { + public SparFile Read(IBinaryReader r, int codepage) + { + byte[] header = r.ReadExactly(264); + using IBinaryReader h = new SafeBinaryReader(header); + float version = h.ReadSingle(); + int count = Count(h, 128); + if (version != 1 || h.ReadString(40) != "__PARTICLE_EFFECT(Softstar_Ltd)_EXPORT__") + throw new InvalidDataException("Unsupported SPar export header"); + var file = new SparFile { Version = version, Groups = new SparGroup[count] }; + for (int i = 0; i < count; i++) + { + using IBinaryReader g = new SafeBinaryReader(r.ReadExactly(544)); + g.ReadUInt32(); // native editor/runtime pointer, not a file offset + var group = new SparGroup { Capacity = Count(g, 65536), VertexCapacity = Count(g, 393216), Mode = g.ReadInt32(), Loop = g.ReadInt32() != 0 }; + int frames = Count(g, 4096), keys = Count(g, 4096); + group.Name = g.ReadString(256, codepage); + group.Texture = g.ReadString(260, codepage); + group.TextureFrames = new float[frames][]; + for (int frame = 0; frame < frames; frame++) + group.TextureFrames[frame] = Floats(r, 4); + group.Keyframes = new SparKeyframe[keys]; + for (int key = 0; key < keys; key++) + group.Keyframes[key] = Keyframe(r); + file.Groups[i] = group; + } + Require(r, 20); + file.Shader = r.ReadInt32(); + file.ShaderParameters = Floats(r, 4); + file.UnusedByteCount = checked((int)(r.Length - r.Position)); + return file; + } + + private static SparKeyframe Keyframe(IBinaryReader r) + { + Require(r, 28); + var key = new SparKeyframe + { + Time = r.ReadSingle(), + Position = r.ReadGameBoxVector3(), + EmissionMinimum = r.ReadInt32(), + EmissionMaximum = r.ReadInt32(), + Shape = r.ReadInt32() + }; + int values = key.Shape switch + { + 0 => 0, + 1 => 1, + 2 => 6, + 3 => checked(Count(r, 1000000) * 3), + _ => throw new InvalidDataException($"Unknown SPar spawn shape {key.Shape}") + }; + key.ShapeParameters = Floats(r, values); + Require(r, 4); + key.HasPath = r.ReadInt32() != 0; + if (key.HasPath) + { + Require(r, 4); + key.PathRate = r.ReadSingle(); + int count = Count(r, 1000000); + Require(r, checked(count * 12)); + key.PathPoints = new Primitives.GameBoxVector3[count]; + for (int i = 0; i < count; i++) + key.PathPoints[i] = r.ReadGameBoxVector3(); + } + else + key.PathPoints = Array.Empty(); + Require(r, 96); + key.VelocityMinimum = r.ReadGameBoxVector3(); + key.VelocityMaximum = r.ReadGameBoxVector3(); + key.RotationMinimum = r.ReadSingle(); + key.RotationMaximum = r.ReadSingle(); + key.SpinMinimum = r.ReadSingle(); + key.SpinMaximum = r.ReadSingle(); + key.ColorMinimum = r.ReadGameBoxVector3(); + key.ColorMaximum = r.ReadGameBoxVector3(); + key.AlphaMinimum = r.ReadSingle(); + key.AlphaMaximum = r.ReadSingle(); + key.LifeMinimum = r.ReadSingle(); + key.LifeMaximum = r.ReadSingle(); + key.SizeMinimum = r.ReadSingle(); + key.SizeMaximum = r.ReadSingle(); + key.FrameMinimum = r.ReadInt32(); + key.FrameMaximum = r.ReadInt32(); + int[][] parameters = { new[] { 0, 0, 3, 4, 0, 0, 0 }, new[] { 0, 0 }, new[] { 0, 0, 6 }, new[] { 0, 0, 0 }, new[] { 0, 1, 1 }, new[] { 0, 4 } }; + key.Controllers = new SparController[parameters.Length]; + for (int i = 0; i < parameters.Length; i++) + { + Require(r, 4); + int kind = r.ReadInt32(); + if ((uint)kind >= (uint)parameters[i].Length) + throw new InvalidDataException($"Unknown SPar controller {i}:{kind}"); + key.Controllers[i] = new SparController { Kind = kind, Parameters = Floats(r, parameters[i][kind]) }; + } + key.Reserved = Floats(r, 4); + return key; + } + + private static int Count(IBinaryReader r, int maximum) + { + Require(r, 4); + int count = r.ReadInt32(); + if (count < 0 || count > maximum) + throw new InvalidDataException($"Invalid SPar count {count}"); + return count; + } + + private static float[] Floats(IBinaryReader r, int count) + { + Require(r, checked(count * 4)); + return r.ReadSingles(count); + } + + private static void Require(IBinaryReader r, int count) + { + if (count > r.Length - r.Position) + throw new EndOfStreamException("Truncated SPar file"); + } + } +} diff --git a/Assets/Scripts/Pal3.Core/DataReader/Par/SparFileReader.cs.meta b/Assets/Scripts/Pal3.Core/DataReader/Par/SparFileReader.cs.meta new file mode 100644 index 000000000..8151938a0 --- /dev/null +++ b/Assets/Scripts/Pal3.Core/DataReader/Par/SparFileReader.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 20cca91ba7574724bd67d2fc91b52af8 diff --git a/Assets/Scripts/Pal3.Core/DataReader/Tga/TgaDecoder.cs b/Assets/Scripts/Pal3.Core/DataReader/Tga/TgaDecoder.cs index abdabadb1..7aae0f9bc 100644 --- a/Assets/Scripts/Pal3.Core/DataReader/Tga/TgaDecoder.cs +++ b/Assets/Scripts/Pal3.Core/DataReader/Tga/TgaDecoder.cs @@ -38,7 +38,7 @@ public static unsafe void Decode24BitDataToRgba32(byte[] data, *dst = *(src + 2); *(dst + 1) = *(src + 1); *(dst + 2) = *src; - *(dst + 3) = 0; // 24-bit don't have alpha + *(dst + 3) = 255; // RGB textures sample with opaque alpha, as in GameBox. } } } @@ -78,9 +78,10 @@ public static unsafe void Decode32BitDataToRgba32(byte[] data, byte alpha = *(src + 3); *(dst + 3) = alpha; - if (alpha != firstAlpha) hasAlphaChannel = true; + if (alpha != firstAlpha) + hasAlphaChannel = true; } } } } -} \ No newline at end of file +} diff --git a/Assets/Scripts/Pal3.Game/Data/GameResourceProvider.cs b/Assets/Scripts/Pal3.Game/Data/GameResourceProvider.cs index 199a889e6..cf9bb33d7 100644 --- a/Assets/Scripts/Pal3.Game/Data/GameResourceProvider.cs +++ b/Assets/Scripts/Pal3.Game/Data/GameResourceProvider.cs @@ -60,14 +60,17 @@ public sealed class GameResourceProvider : IDisposable, private ITextureCache _textureCache; - private readonly Dictionary _spriteCache = new (); - private readonly Dictionary _audioClipCache = new (); + private readonly Dictionary _spriteCache = new(); + private readonly Dictionary _audioClipCache = new(); - private readonly Dictionary> _gameResourceFileCache = new (); + private readonly Dictionary> _gameResourceFileCache = new(); private readonly int _codepage; - internal OptionalVfxPlayer OptionalVfx { get; } + internal EffectRuntime Effects + { + get; + } public GameResourceProvider(ICpkFileSystem fileSystem, ITextureLoaderFactory textureLoaderFactory, @@ -83,7 +86,7 @@ public GameResourceProvider(ICpkFileSystem fileSystem, _codepage = _gameSettings.Language == Language.SimplifiedChinese ? 936 : 950; _gameDatabase = GetGameDatabaseFile(); // Initialize game database file - OptionalVfx = new OptionalVfxPlayer(_gameSettings); + Effects = new EffectRuntime(_fileSystem, _codepage); CommandExecutorRegistry.Instance.Register(this); } @@ -112,7 +115,7 @@ public void Dispose() } _audioClipCache.Clear(); - OptionalVfx.Dispose(); + Effects.Dispose(); _materialManager.Dispose(); @@ -131,9 +134,11 @@ public bool FileExists(string path) // No need to deallocate the shadow texture/sprite since it is been used almost every where. private static ISprite _shadowSprite; + public ISprite GetShadowSprite() { - if (_shadowSprite != null) return _shadowSprite; + if (_shadowSprite != null) + return _shadowSprite; ITexture2D shadowTexture = new UnityTexture2D(Resources.Load("Textures/shadow")); _shadowSprite = shadowTexture.CreateSprite(0, 0, @@ -143,6 +148,8 @@ public ISprite GetShadowSprite() return _shadowSprite; } + public Effect.Original.OriginalEffectResources OriginalEffects => Effects.Original; + public IMaterialManager GetMaterialManager() => _materialManager; public ITextureResourceProvider CreateTextureResourceProvider(string relativeDirectoryPath, bool useCache = true, TextureUsage usage = TextureUsage.World) @@ -224,7 +231,8 @@ public string GetMusicFilePathInCacheFolder(string musicFileVirtualPath) /// music file path in cache folder public IEnumerator ExtractAndMoveMp3FileToCacheFolderAsync(string musicFileVirtualPath, string musicFileCachePath) { - if (File.Exists(musicFileCachePath)) yield break; + if (File.Exists(musicFileCachePath)) + yield break; EngineLogger.Log($"Writing MP3 file to App's persistent folder: {musicFileVirtualPath}"); Thread dataMovementThread = new(() => @@ -253,11 +261,11 @@ public IEnumerator ExtractAndMoveMp3FileToCacheFolderAsync(string musicFileVirtu public string GetSfxFilePath(string sfxName) { - #if PAL3 +#if PAL3 string sfxFileName = (sfxName + ".wav").ToLower(); - #elif PAL3A +#elif PAL3A string sfxFileName = (sfxName + ".wav").ToUpper(); - #endif +#endif string sfxFileRelativePath = $"{FileConstants.SfxFolderName}{Path.DirectorySeparatorChar}" + sfxFileName; @@ -293,13 +301,13 @@ public IEnumerator LoadAudioClipAsync(string filePath, public ISprite GetLogoSprite() { - #if PAL3 +#if PAL3 const string mainLogoTextureFileName = "11.tga"; Rect mainLogoSpriteRect = new Rect(0, 600, 375, 145); - #elif PAL3A +#elif PAL3A const string mainLogoTextureFileName = "12.tga"; Rect mainLogoSpriteRect = new Rect(0, 770, 500, 253); - #endif +#endif ITextureResourceProvider uiLibTextureProvider = CreateTextureResourceProvider(FileConstants.UILibFolderVirtualPath, usage: TextureUsage.Sprite); ITexture2D logoTexture = uiLibTextureProvider.GetTexture(mainLogoTextureFileName); @@ -321,13 +329,15 @@ public ISprite GetActorAvatarSprite(string actorName, string avatarName) if (_spriteCache.TryGetValue(cacheKey, out ISprite sprite)) { - if (sprite is { Texture: { IsNativeObjectDisposed: false } }) return sprite; + if (sprite is { Texture: { IsNativeObjectDisposed: false } }) + return sprite; } ITexture2D texture = GetActorAvatarTexture( actorName, avatarName); - if (texture == null) return null; + if (texture == null) + return null; ISprite avatarSprite = texture.CreateSprite( // Cut 2f to hide artifacts near edges for some of the avatar textures @@ -527,6 +537,7 @@ public ITexture2D GetCursorTexture() } private string _currentCityName; + public void Execute(ScenePreLoadingNotification notification) { string newCityName = notification.NewSceneInfo.CityName.ToLower(); @@ -590,8 +601,8 @@ private void DisposeTemporaryInMemoryGameResources() } } - OptionalVfx.ClearPrefabCache(); + Effects.InvalidatePreloads(); } } -} \ No newline at end of file +} diff --git a/Assets/Scripts/Pal3.Game/Dev/MainMenu.cs b/Assets/Scripts/Pal3.Game/Dev/MainMenu.cs index e8bbe2305..788f431a7 100644 --- a/Assets/Scripts/Pal3.Game/Dev/MainMenu.cs +++ b/Assets/Scripts/Pal3.Game/Dev/MainMenu.cs @@ -513,8 +513,6 @@ private void SetupVisualEffectButtons() value => _gameSettings.ToonFireShadows = value); AddOption("泛光", () => _gameSettings.BloomEnabled, value => _gameSettings.BloomEnabled = value); - AddOption("可选剧情特效", () => _gameSettings.OptionalVfxEnabled, - value => _gameSettings.OptionalVfxEnabled = value); CreateMenuButton("返回", _ => delegate { diff --git a/Assets/Scripts/Pal3.Game/Effect/CombatEffect.cs b/Assets/Scripts/Pal3.Game/Effect/CombatEffect.cs index 4bf6918de..8afac2985 100644 --- a/Assets/Scripts/Pal3.Game/Effect/CombatEffect.cs +++ b/Assets/Scripts/Pal3.Game/Effect/CombatEffect.cs @@ -8,30 +8,9 @@ namespace Pal3.Game.Effect using System; using Data; using Engine.Core.Implementation; - using Engine.Extensions; - - using Vector3 = UnityEngine.Vector3; public sealed class CombatEffect : GameEntityScript, IEffect { - #if PAL3 - private const string SHUSHAN_BRIDGE_TEXTURE_NAME = "q08qiao.dds"; - #elif PAL3A - private const string SHUSHAN_BRIDGE_TEXTURE_NAME = "q08qiao.tga"; - #endif - private const float SHUSHAN_BRIDGE_DEFAULT_SIZE = 3.5f; - private const float SHUSHAN_BRIDGE_ANIMATION_ROTATION_SPEED = 15f; - - #if PAL3 - private const string SHUSHAN_BRIDGE_EFFECT_TEXTURE_NAME = "q08qiao2.dds"; - #elif PAL3A - private const string SHUSHAN_BRIDGE_EFFECT_TEXTURE_NAME = "q08qiao2.tga"; - #endif - private const float SHUSHAN_BRIDGE_EFFECT_DEFAULT_SIZE = 3.5f; - private const float SHUSHAN_BRIDGE_EFFECT_ANIMATION_ROTATION_SPEED = 40f; - - private RotatingSpriteEffect _bridgeBaseEffect; - private RotatingSpriteEffect _bridgeEffect; private IDisposable _effect; protected override void OnDisableGameEntity() @@ -41,59 +20,16 @@ protected override void OnDisableGameEntity() public void Init(GameResourceProvider resourceProvider, uint effectParameter) { - #if PAL3 - if (effectParameter == 465) // 蜀山太极桥特效 - #elif PAL3A - if (effectParameter == 160) // 蜀山太极桥特效 - #endif - { - _bridgeBaseEffect = GameEntity.AddComponent(); - _bridgeBaseEffect.Init(resourceProvider, - SHUSHAN_BRIDGE_TEXTURE_NAME, - new Vector3(SHUSHAN_BRIDGE_DEFAULT_SIZE, - SHUSHAN_BRIDGE_DEFAULT_SIZE, - SHUSHAN_BRIDGE_DEFAULT_SIZE), - SHUSHAN_BRIDGE_ANIMATION_ROTATION_SPEED); - - _bridgeEffect = GameEntity.AddComponent(); - _bridgeEffect.Init(resourceProvider, - SHUSHAN_BRIDGE_EFFECT_TEXTURE_NAME, - new Vector3(SHUSHAN_BRIDGE_EFFECT_DEFAULT_SIZE, - SHUSHAN_BRIDGE_EFFECT_DEFAULT_SIZE, - SHUSHAN_BRIDGE_EFFECT_DEFAULT_SIZE), - SHUSHAN_BRIDGE_EFFECT_ANIMATION_ROTATION_SPEED); - } - - #if PAL3 - if (effectParameter == 275) // 重楼创建的用来禁锢雪见/龙葵的法阵特效 - #elif PAL3A - if (effectParameter == 344) // 重盘古元灵特效 - #endif - { - _effect = resourceProvider.OptionalVfx.Attach((int)effectParameter, GameEntity); - } + // Scene effects use the same native group scheduler as script effects. + _effect = resourceProvider.Effects.Attach((int)effectParameter, GameEntity); } private void OnDestroy() => Dispose(); public void Dispose() { - if (_bridgeBaseEffect != null) - { - _bridgeBaseEffect.Dispose(); - _bridgeBaseEffect.Destroy(); - _bridgeBaseEffect = null; - } - - if (_bridgeEffect != null) - { - _bridgeEffect.Dispose(); - _bridgeEffect.Destroy(); - _bridgeEffect = null; - } - _effect?.Dispose(); _effect = null; } } -} \ No newline at end of file +} diff --git a/Assets/Scripts/Pal3.Game/Effect/EffectManager.cs b/Assets/Scripts/Pal3.Game/Effect/EffectManager.cs index ba0b5eb36..f5554a3b9 100644 --- a/Assets/Scripts/Pal3.Game/Effect/EffectManager.cs +++ b/Assets/Scripts/Pal3.Game/Effect/EffectManager.cs @@ -30,9 +30,12 @@ public sealed class EffectManager : IDisposable, ICommandExecutor, ICommandExecutor { - private readonly OptionalVfxPlayer _optionalVfx; + private readonly EffectRuntime _effects; private readonly SceneManager _sceneManager; - private ICommand _effectPositionCommand; +#if PAL3A + private EffectAttachToActorCommand _attachedActor; +#endif + private Vector3 _originalPosition; private const string EFFECT_LINKER_FILE_NAME = "efflinker.dat"; private EffectLinkerFile _effectLinkerFile; @@ -40,7 +43,7 @@ public sealed class EffectManager : IDisposable, public EffectManager(GameResourceProvider resourceProvider, SceneManager sceneManager) { - _optionalVfx = Requires.IsNotNull(resourceProvider, nameof(resourceProvider)).OptionalVfx; + _effects = Requires.IsNotNull(resourceProvider, nameof(resourceProvider)).Effects; _sceneManager = Requires.IsNotNull(sceneManager, nameof(sceneManager)); _effectLinkerFile = resourceProvider.GetGameResourceFile( @@ -61,48 +64,27 @@ public void Execute(EffectPreLoadCommand command) // So it is pointless to pre-load the effect here for PAL3A. // Even with a waiter, we need to make sure waiter's current execution mode is set // to synchronous otherwise the effect will be played before the pre-load is finished. - #if PAL3 - Pal3.Instance.StartCoroutine(_optionalVfx.PreloadAsync(command.EffectGroupId)); - #endif +#if PAL3 + Pal3.Instance.StartCoroutine(_effects.PreloadAsync(command.EffectGroupId)); +#endif } public void Execute(EffectPlayCommand command) { - if (_effectPositionCommand == null || - _sceneManager.GetCurrentScene() is not {} currentScene) return; - - #if PAL3A - if (_effectPositionCommand is EffectAttachToActorCommand effectAttachToActorCommand) - { - HandleNanGongHuangTransformEffect(effectAttachToActorCommand, command, currentScene); - } - #endif - - if (_effectPositionCommand is EffectSetPositionCommand positionCommand) - { - Vector3 localPosition = new GameBoxVector3( - positionCommand.GameBoxXPosition, - positionCommand.GameBoxYPosition, - positionCommand.GameBoxZPosition).ToUnityPosition(); - - _optionalVfx.Play(command.EffectGroupId, - _sceneManager.GetSceneRootGameEntity(), localPosition); - } - else if (_effectPositionCommand is EffectAttachToActorCommand actorCommand) - { - _optionalVfx.Play(command.EffectGroupId, - currentScene.GetActorGameEntity(actorCommand.ActorId), Vector3.zero); - } - - // Play SFX if any - if (EffectConstants.EffectSfxInfo.TryGetValue(command.EffectGroupId, out var sfxName)) - { - Pal3.Instance.Execute( - new PlaySfxCommand(sfxName, 1)); - } + if (_sceneManager.GetCurrentScene() is not { } currentScene) + return; +#if PAL3A + if (_attachedActor is { } attach && + !currentScene.GetActorGameEntity(attach.ActorId).IsNullOrDisposed()) + HandleNanGongHuangTransformEffect(attach, command, currentScene); +#endif + // Both games retain the last position when cmd_CEft_Role cannot find + // its actor. Playback uses that snapshot even if the actor is gone. + _effects.Play(command.EffectGroupId, + _sceneManager.GetSceneRootGameEntity(), _originalPosition); } - #if PAL3A +#if PAL3A private void HandleNanGongHuangTransformEffect(EffectAttachToActorCommand effectAttachToActorCommand, EffectPlayCommand effectPlayCommand, GameScene currentScene) @@ -138,11 +120,32 @@ private void HandleNanGongHuangTransformEffect(EffectAttachToActorCommand effect } } } - #endif +#endif - public void Execute(EffectSetPositionCommand command) => _effectPositionCommand = command; + public void Execute(EffectSetPositionCommand command) + { +#if PAL3A + _attachedActor = null; +#endif + _originalPosition = new GameBoxVector3(command.GameBoxXPosition, command.GameBoxYPosition, command.GameBoxZPosition).ToUnityPosition(); + } - public void Execute(EffectAttachToActorCommand command) => _effectPositionCommand = command; + public void Execute(EffectAttachToActorCommand command) + { +#if PAL3A + _attachedActor = command; +#endif + var scene = _sceneManager.GetCurrentScene(); + var actor = scene?.GetActorGameEntity(command.ActorId); + if (!actor.IsNullOrDisposed()) + { + var root = (UnityEngine.Transform)_sceneManager.GetSceneRootGameEntity().Transform.NativeObject; + var transform = (UnityEngine.Transform)actor.Transform.NativeObject; + // cmd_CEft_Role captures the position immediately and adds two + // GameBox units. It does not attach the effect to a moving actor. + _originalPosition = root.InverseTransformPoint(transform.position) + Vector3.up * 0.1f; + } + } public void Execute(SceneLeavingCurrentSceneNotification command) => ClearSceneEffects(); @@ -150,8 +153,11 @@ private void HandleNanGongHuangTransformEffect(EffectAttachToActorCommand effect private void ClearSceneEffects() { - _effectPositionCommand = null; - _optionalVfx.ClearPlayedEffects(); +#if PAL3A + _attachedActor = null; +#endif + _originalPosition = Vector3.zero; + _effects.ClearPlayedEffects(); } } -} \ No newline at end of file +} diff --git a/Assets/Scripts/Pal3.Game/Effect/EffectRuntime.cs b/Assets/Scripts/Pal3.Game/Effect/EffectRuntime.cs new file mode 100644 index 000000000..c90faace5 --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/EffectRuntime.cs @@ -0,0 +1,243 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Pal3.Game.Effect +{ + using System; + using System.Collections; + using System.Collections.Generic; + using Original; + using Core.FileSystem; + using Core.Command.SceCommands; + using Engine.Core.Abstraction; + using Engine.Core.Implementation; + using Engine.Extensions; + using Engine.Logging; + using UnityEngine; + + internal sealed class EffectRuntime : IDisposable + { + private readonly OriginalEffectResources _original; + private readonly HashSet _failures = new(); + private readonly List _playedEffects = new(); + private readonly List _persistentEffects = new(); + private int _preloadGeneration; + private bool _disposed; + + internal OriginalEffectResources Original => _original; + + internal EffectRuntime(ICpkFileSystem fileSystem, int codepage) + { + _original = new OriginalEffectResources(fileSystem, codepage); + } + + internal IEnumerator PreloadAsync(int groupId) + { + if (_disposed) + yield break; + int generation = _preloadGeneration; + OriginalEffectOverrides edits; + try + { + edits = Resources.Load(_original.OverridesPath); + string[] issues = _original.Diagnose(groupId, edits); + if (issues.Length != 0) + { + Report(groupId, string.Join("\n", issues)); + yield break; + } + } + catch (Exception ex) + { + Report(groupId, ex.Message); + yield break; + } + using var preload = _original.Preload(groupId, edits).GetEnumerator(); + while (!_disposed && generation == _preloadGeneration) + { + bool next; + try + { + next = preload.MoveNext(); + } + catch (Exception ex) + { + Report(groupId, ex.Message); + break; + } + if (!next) + break; + yield return null; + } + } + + private void Report(int groupId, string message) + { + if (_failures.Add(groupId)) + EngineLogger.LogWarning($"Effect {groupId}: {message}"); + } + + private IGameEntity Create(int groupId, IGameEntity parent, uint portalType = 0) + { + if (_disposed || parent.IsNullOrDisposed()) + return null; + GameObject root = null; + try + { + var edits = Resources.Load(_original.OverridesPath); + string[] issues = groupId == -1 ? Array.Empty() : _original.Diagnose(groupId, edits); + if (issues.Length != 0) + { + Report(groupId, string.Join("\n", issues)); + return null; + } + root = new GameObject(groupId == -1 ? "Original transport" : $"Original effect {groupId}"); + root.transform.SetParent((Transform)parent.Transform.NativeObject, false); + if (groupId == -1) + root.AddComponent().Initialize(_original, portalType); + else + { + var player = root.AddComponent(); + player.Initialize(_original, groupId, (uint)UnityEngine.Random.Range(1, int.MaxValue), overrides: edits, + sound: name => Pal3.Instance.Execute(new PlaySfxCommand(name, 1))); + player.DestroyOnComplete = true; + if (player.Error != null) + throw new InvalidOperationException(player.Error); + } + return new GameEntity(root); + } + catch (Exception ex) + { + if (root != null) + { + root.SetActive(false); + OriginalEffectResources.Destroy(root); + } + Report(groupId, ex.Message); + return null; + } + } + + internal bool Play(int groupId, IGameEntity parent, Vector3 localPosition) + { + if (Create(groupId, parent) is not { } instance) + return false; + instance.Transform.LocalPosition += localPosition; + _playedEffects.RemoveAll(effect => effect.IsNativeObjectDisposed); + _playedEffects.Add(instance); + return true; + } + + internal IDisposable AttachPortal(IGameEntity parent, uint type = 0) => Attach(-1, parent, type); + + internal IDisposable Attach(int groupId, IGameEntity parent, uint portalType = 0) + { + if (_disposed || parent.IsNullOrDisposed()) + return null; + PruneDeadAttachments(); + var effect = new PersistentEffect(this, groupId, parent, portalType); + _persistentEffects.Add(effect); + return effect; + } + + internal void ClearPlayedEffects() + { + foreach (IGameEntity effect in _playedEffects) + Stop(effect); + _playedEffects.Clear(); + InvalidatePreloads(); + _failures.Clear(); + if (_disposed) + return; + if (Application.isPlaying && Pal3.Instance != null) + Pal3.Instance.StartCoroutine(TrimSceneRenderAssetsAfterFrame()); + else + _original.TrimUnusedRenderAssets(LiveRoots()); + } + + private IEnumerator TrimSceneRenderAssetsAfterFrame() + { + // Scene objects are destroyed at the end of the leaving frame. + yield return null; + if (_disposed) + yield break; + PruneDeadAttachments(); + _original.TrimUnusedRenderAssets(LiveRoots()); + } + + private IEnumerable LiveRoots() + { + foreach (IGameEntity effect in _playedEffects) + if (!effect.IsNullOrDisposed()) + yield return (GameObject)effect.NativeObject; + foreach (PersistentEffect effect in _persistentEffects) + if (effect.Root != null) + yield return effect.Root; + } + + private static void Stop(IGameEntity effect) + { + if (effect.IsNullOrDisposed()) + return; + ((GameObject)effect.NativeObject).SetActive(false); + effect.Destroy(); + } + + private void PruneDeadAttachments() + { + for (int i = _persistentEffects.Count - 1; i >= 0; i--) + if (_persistentEffects[i].ParentIsDisposed) + _persistentEffects[i].Dispose(); + } + + internal void InvalidatePreloads() + { + PruneDeadAttachments(); + _preloadGeneration++; + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + ClearPlayedEffects(); + foreach (PersistentEffect effect in _persistentEffects.ToArray()) + effect.Dispose(); + _original.Dispose(); + } + + private sealed class PersistentEffect : IDisposable + { + private readonly EffectRuntime _owner; + private readonly IGameEntity _parent; + private IGameEntity _instance; + + internal bool ParentIsDisposed => _parent.IsNullOrDisposed(); + + internal GameObject Root => _instance.IsNullOrDisposed() ? null : (GameObject)_instance.NativeObject; + + internal PersistentEffect(EffectRuntime owner, int groupId, IGameEntity parent, uint portalType) + { + _owner = owner; + _parent = parent; + _instance = owner.Create(groupId, parent, portalType); + if (Root != null && Root.GetComponent() is { } player) + { + player.DestroyOnComplete = false; + player.Loop = true; + player.RepeatInterval = owner._original.GameVariant == "PAL3" && groupId == 275 ? 9.5f : 0; + } + } + + public void Dispose() + { + _owner._persistentEffects.Remove(this); + Stop(_instance); + _instance = null; + } + } + } +} diff --git a/Assets/Scripts/Pal3.Game/Effect/OptionalVfxPlayer.cs.meta b/Assets/Scripts/Pal3.Game/Effect/EffectRuntime.cs.meta similarity index 100% rename from Assets/Scripts/Pal3.Game/Effect/OptionalVfxPlayer.cs.meta rename to Assets/Scripts/Pal3.Game/Effect/EffectRuntime.cs.meta diff --git a/Assets/Scripts/Pal3.Game/Effect/OptionalVfxPlayer.cs b/Assets/Scripts/Pal3.Game/Effect/OptionalVfxPlayer.cs deleted file mode 100644 index 2aabf2755..000000000 --- a/Assets/Scripts/Pal3.Game/Effect/OptionalVfxPlayer.cs +++ /dev/null @@ -1,198 +0,0 @@ -// ------------------------------------------------------------------------ -// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. -// See LICENSE file in the project root for license information. -// ------------------------------------------------------------------------ - -namespace Pal3.Game.Effect -{ - using System; - using System.Collections; - using System.Collections.Generic; - using Constants; - using Engine.Core.Abstraction; - using Engine.Core.Implementation; - using Engine.Extensions; - using Engine.Logging; - using Settings; - using UnityEngine; - - internal sealed class OptionalVfxPlayer : IDisposable - { - private readonly GameSettings _settings; - private readonly Dictionary _prefabs = new(); - private readonly List _playedEffects = new(); - private readonly List _persistentEffects = new(); - - private int _prefabCacheGeneration; - private bool _disposed; - private bool IsEnabled => !_disposed && _settings.OptionalVfxEnabled; - - internal OptionalVfxPlayer(GameSettings settings) - { - _settings = settings; - _settings.OnGameSettingsChanged += OnGameSettingsChanged; - } - - private static string GetPrefabPath(int effectGroupId) => $"Prefabs/VFX/{GameConstants.AppName}/{effectGroupId}"; - - internal IEnumerator PreloadAsync(int effectGroupId) - { - if (!IsEnabled || _prefabs.ContainsKey(effectGroupId)) yield break; - - int generation = _prefabCacheGeneration; - ResourceRequest request = Resources.LoadAsync(GetPrefabPath(effectGroupId)); - yield return request; - - if (IsEnabled && generation == _prefabCacheGeneration && !_prefabs.ContainsKey(effectGroupId)) - { - CachePrefab(effectGroupId, request.asset as GameObject); - } - } - - private void CachePrefab(int effectGroupId, GameObject prefab) - { - _prefabs[effectGroupId] = prefab; - if (prefab == null) - { - EngineLogger.LogWarning($"VFX prefab not found: {effectGroupId}"); - } - } - - private IGameEntity Create(int effectGroupId, IGameEntity parent) - { - if (!IsEnabled || parent.IsNullOrDisposed()) return null; - - if (!_prefabs.TryGetValue(effectGroupId, out GameObject prefab)) - { - prefab = Resources.Load(GetPrefabPath(effectGroupId)); - CachePrefab(effectGroupId, prefab); - } - - return prefab == null ? null : - GameEntityFactory.Create($"VFX_{effectGroupId}", prefab, parent, worldPositionStays: false); - } - - internal void Play(int effectGroupId, IGameEntity parent, Vector3 localPosition) - { - if (Create(effectGroupId, parent) is not {} instance) return; - - instance.Transform.LocalPosition += localPosition; - _playedEffects.RemoveAll(effect => effect.IsNativeObjectDisposed); - _playedEffects.Add(instance); - } - - internal IDisposable Attach(int effectGroupId, IGameEntity parent) - { - if (_disposed || parent.IsNullOrDisposed()) return null; - PruneDeadAttachments(); - var effect = new PersistentEffect(this, effectGroupId, parent); - _persistentEffects.Add(effect); - effect.Refresh(); - return effect; - } - - private void OnGameSettingsChanged(string propertyName) - { - if (propertyName != nameof(GameSettings.OptionalVfxEnabled)) return; - - if (!IsEnabled) ClearPlayedEffects(); - - foreach (PersistentEffect effect in _persistentEffects.ToArray()) - { - effect.Refresh(); - } - } - - internal void ClearPlayedEffects() - { - foreach (IGameEntity effect in _playedEffects) - { - Stop(effect); - } - _playedEffects.Clear(); - } - - private static void Stop(IGameEntity effect) - { - if (effect.IsNullOrDisposed()) return; - - // Destroy is deferred to the end of the frame. - ((GameObject)effect.NativeObject).SetActive(false); - effect.Destroy(); - } - - private void PruneDeadAttachments() - { - for (int i = _persistentEffects.Count - 1; i >= 0; i--) - { - if (_persistentEffects[i].ParentIsDisposed) _persistentEffects[i].Dispose(); - } - } - - internal void ClearPrefabCache() - { - PruneDeadAttachments(); - _prefabCacheGeneration++; - _prefabs.Clear(); - } - - public void Dispose() - { - _disposed = true; - _settings.OnGameSettingsChanged -= OnGameSettingsChanged; - ClearPlayedEffects(); - - foreach (PersistentEffect effect in _persistentEffects.ToArray()) - { - effect.Dispose(); - } - ClearPrefabCache(); - } - - private sealed class PersistentEffect : IDisposable - { - private readonly OptionalVfxPlayer _player; - private readonly int _effectGroupId; - private readonly IGameEntity _parent; - private IGameEntity _instance; - private bool _disposed; - - internal PersistentEffect(OptionalVfxPlayer player, int effectGroupId, IGameEntity parent) - { - _player = player; - _effectGroupId = effectGroupId; - _parent = parent; - } - - internal bool ParentIsDisposed => _parent.IsNullOrDisposed(); - - internal void Refresh() - { - if (_disposed) return; - if (ParentIsDisposed) - { - Dispose(); - return; - } - - if (_player.IsEnabled) - { - if (_instance.IsNullOrDisposed()) _instance = _player.Create(_effectGroupId, _parent); - } - else - { - Stop(_instance); - _instance = null; - } - } - - public void Dispose() - { - _disposed = true; - _player._persistentEffects.Remove(this); - Stop(_instance); - _instance = null; - } - } - } -} diff --git a/Assets/Scripts/Pal3.Game/Effect/Original.meta b/Assets/Scripts/Pal3.Game/Effect/Original.meta new file mode 100644 index 000000000..85f5df756 --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ce61acd16e807473ea038ae337099077 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/EffectMeshBuffer.cs b/Assets/Scripts/Pal3.Game/Effect/Original/EffectMeshBuffer.cs new file mode 100644 index 000000000..2cbf7e11b --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/EffectMeshBuffer.cs @@ -0,0 +1,86 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Pal3.Game.Effect.Original +{ + using System; + using System.Collections.Generic; + using UnityEngine; + using UnityEngine.Rendering; + + internal sealed class EffectMeshBuffer : IDisposable + { + private readonly Mesh _mesh; + private readonly bool _reverseWinding; + private readonly MeshRenderer _renderer; + private readonly List _vertices = new(); + private readonly List _uv = new(); + private readonly List _colors = new(); + private readonly List _indices = new(); + + public int QuadCount => _vertices.Count / 4; + + public EffectMeshBuffer(GameObject root, Material material, bool reverseWinding = false) + { + _reverseWinding = reverseWinding; + _mesh = new Mesh { name = "Original effect mesh", indexFormat = IndexFormat.UInt32, hideFlags = HideFlags.DontSave }; + _mesh.MarkDynamic(); + root.AddComponent().sharedMesh = _mesh; + _renderer = root.AddComponent(); + _renderer.sharedMaterial = material; + _renderer.shadowCastingMode = ShadowCastingMode.Off; + _renderer.receiveShadows = false; + _renderer.lightProbeUsage = LightProbeUsage.Off; + _renderer.reflectionProbeUsage = ReflectionProbeUsage.Off; + } + + public void Clear() + { + _vertices.Clear(); + _uv.Clear(); + _colors.Clear(); + _indices.Clear(); + } + + public void Quad(Vector3 a, Vector3 b, Vector3 c, Vector3 d, Vector2 uv0, Vector2 uv1, Color32 color) + => Quad(a, b, c, d, uv0, uv1, color, color, color, color); + + public void Quad(Vector3 a, Vector3 b, Vector3 c, Vector3 d, Vector2 uv0, Vector2 uv1, + Color32 colorA, Color32 colorB, Color32 colorC, Color32 colorD) + { + int n = _vertices.Count; + _vertices.Add(a); + _vertices.Add(b); + _vertices.Add(c); + _vertices.Add(d); + _uv.Add(uv0); + _uv.Add(new Vector2(uv1.x, uv0.y)); + _uv.Add(uv1); + _uv.Add(new Vector2(uv0.x, uv1.y)); + _colors.Add(colorA); + _colors.Add(colorB); + _colors.Add(colorC); + _colors.Add(colorD); + _indices.Add(n); + _indices.Add(n + (_reverseWinding ? 2 : 1)); + _indices.Add(n + (_reverseWinding ? 1 : 2)); + _indices.Add(n); + _indices.Add(n + (_reverseWinding ? 3 : 2)); + _indices.Add(n + (_reverseWinding ? 2 : 3)); + } + + public void Upload() + { + _renderer.enabled = _vertices.Count > 0; + _mesh.Clear(false); + _mesh.SetVertices(_vertices); + _mesh.SetUVs(0, _uv); + _mesh.SetColors(_colors); + _mesh.SetTriangles(_indices, 0, true); + } + + public void Dispose() => OriginalEffectResources.Destroy(_mesh); + } +} diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/EffectMeshBuffer.cs.meta b/Assets/Scripts/Pal3.Game/Effect/Original/EffectMeshBuffer.cs.meta new file mode 100644 index 000000000..3ce774473 --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/EffectMeshBuffer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4daa7814a3fa8493293fc2ad9eb6fecc \ No newline at end of file diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectNode.cs b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectNode.cs new file mode 100644 index 000000000..8ba1bf25e --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectNode.cs @@ -0,0 +1,193 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Pal3.Game.Effect.Original +{ + using System; + using Core.DataReader.Data; + using UnityEngine; + using Color = UnityEngine.Color; + + internal sealed class OriginalTaskClock + { + private bool _initialized, _finished; + private float _initAt; + + public float StartedAt { get; private set; } = -1; + + public bool StartedNow + { + get; private set; + } + + public bool Finished => _finished; + + public bool Tick(float time, float dt, EffectTask task, out float elapsed) + { + StartedNow = false; + elapsed = 0; + if (_finished) + return false; + if (!_initialized) + { + _initialized = true; + _initAt = time; + // A negative wait means "after the previous init subtask", + // which has already finished in this update in CTaskTpl. + if (task.StartTime >= 0) + return false; + StartedAt = time; + StartedNow = true; + } + if (StartedAt < 0) + { + if (time - _initAt <= Mathf.Max(0, task.StartTime)) + return false; + StartedAt = time; + StartedNow = true; + } + elapsed = dt - Mathf.Max(0, time - StartedAt - task.Duration); + _finished = time - StartedAt + 0.000001f >= task.Duration; + return true; + } + } + + internal abstract class OriginalEffectNode : IDisposable + { + protected readonly GameObject Root; + protected readonly EffectDefinition Definition; + protected readonly EffectTask[] Tasks; + protected readonly OriginalTaskClock[] Clocks; + protected readonly float Birth; + protected Vector3 Position, Rotation, Orbit, Scale; + protected Color Tint; + protected float TextureOffset, LocalTime; + protected bool Visible; + + public virtual bool Alive { get; protected set; } = true; + public virtual int QuadCount => 0; + + protected OriginalEffectNode(GameObject root, EffectDefinition d, EffectTask[] tasks, Vector3 origin, float birth) + { + Root = root; + Definition = d; + Tasks = tasks ?? Array.Empty(); + Birth = birth; + Position = origin + new Vector3(d.InitXPosition, d.InitYPosition, d.InitZPosition); + Rotation = new Vector3(d.InitXRotation, d.InitYRotation, d.InitZRotation); + Scale = Vector3.one * d.InitSize; + Tint = PackedColor(d.InitColor); + Clocks = new OriginalTaskClock[Tasks.Length]; + for (int i = 0; i < Clocks.Length; i++) + Clocks[i] = new OriginalTaskClock(); + } + + public virtual void Step(float time, float dt) + { + LocalTime = time - Birth; + bool alive = false; + for (int i = 0; i < Tasks.Length; i++) + { + if (Clocks[i].Tick(LocalTime, dt, Tasks[i], out float activeDt)) + { + Visible = true; + Apply(Tasks[i], Clocks[i], Mathf.Max(0, activeDt)); + } + alive |= !Clocks[i].Finished; + } + Alive = alive; + } + + protected virtual void Apply(EffectTask t, OriginalTaskClock clock, float dt) + { + Position += new Vector3(t.SpeedX, t.SpeedY, t.SpeedZ) * dt; + ApplyRotationAndStretch(t, dt); + Tint = ChangedColor(Tint, t, dt); + Vector3 axis = new(t.CircleAxisX, t.CircleAxisY, t.CircleAxisZ); + Orbit = OrbitPosition(axis, (LocalTime - clock.StartedAt) * t.CircleSpeed, t.CircleRadius); + TextureOffset += t.CreateTimeSlot * dt; + } + + protected virtual void ApplyRotationAndStretch(EffectTask t, float dt) + { + Rotation += new Vector3(t.RotAxisX, t.RotAxisY, t.RotAxisZ) * dt; + Scale += new Vector3(t.XStretchSpeed, t.YStretchSpeed, t.CreateTimeSlot) * dt; + } + + public abstract void Render(Camera camera); + + public virtual void Dispose() + { + if (Root != null) + Root.SetActive(false); + OriginalEffectResources.Destroy(Root); + } + + internal static Vector3 UnityPosition(Vector3 v) => new(-v.x / 20, v.y / 20, v.z / 20); + + // A zero axis produces an identity matrix in GameBox, preserving the + // input radius vector rather than discarding the offset. + internal static Vector3 OrbitPosition(Vector3 axis, float angle, float radius) => + (axis.sqrMagnitude > 0 ? Quaternion.AngleAxis(angle, axis.normalized) : Quaternion.identity) * new Vector3(0, 0, radius); + + internal static Quaternion UnityRotation(Vector3 r) => Quaternion.AngleAxis(r.x, Vector3.right) * Quaternion.AngleAxis(-r.y, Vector3.up) * Quaternion.AngleAxis(-r.z, Vector3.forward); + + internal static Quaternion RawRotation(Vector3 r) => Quaternion.AngleAxis(r.x, Vector3.right) * Quaternion.AngleAxis(r.y, Vector3.up) * Quaternion.AngleAxis(r.z, Vector3.forward); + + internal static Color PackedColor(uint c) => new((c >> 16 & 255) / 255f, (c >> 8 & 255) / 255f, (c & 255) / 255f, (c >> 24) / 255f); + + internal static Color ChangedColor(Color c, EffectTask t, float dt) => new(Mathf.Clamp01(c.r + t.SpeedRed * dt / 255), + Mathf.Clamp01(c.g + t.SpeedGreen * dt / 255), Mathf.Clamp01(c.b + t.SpeedBlue * dt / 255), Mathf.Clamp01(c.a + t.SpeedAlpha * dt / 255)); + + protected (Vector2 min, Vector2 max) Atlas(float age) + { + int columns = Mathf.Max(1, Definition.FramesPerRow), rows = Mathf.Max(1, Definition.FramesPerColumn); + int frame = Mathf.Abs(Mathf.RoundToInt(age * Definition.FramePerSecond)) % Mathf.Max(1, Definition.FrameCount); + var uv = new Vector2((float)(frame % columns) / columns, 1f - (float)(frame / columns + 1) / rows); + return (uv, uv + new Vector2(1f / columns, 1f / rows)); + } + } + + internal sealed class OriginalControlNode : OriginalEffectNode + { + private readonly OriginalEffectPlayer _player; + private readonly string _kind; + private readonly byte[] _played; + + public OriginalControlNode(GameObject root, EffectDefinition d, EffectTask[] tasks, Vector3 origin, float birth, OriginalEffectPlayer player, string kind) + : base(root, d, tasks, origin, birth) + { + _player = player; + _kind = kind; + _played = new byte[Tasks.Length]; + } + + protected override void Apply(EffectTask t, OriginalTaskClock clock, float dt) + { + if (_kind == "callback") + { + if (clock.StartedNow) + foreach (var child in t.Callbacks()) + _player.AddGroup(child.groupId, Position + new Vector3(child.position.X, child.position.Y, child.position.Z)); + } + else + { + int index = Array.IndexOf(Tasks, t), bit = 0; + foreach (var sound in t.Sounds()) + { + int mask = 1 << bit++; + if ((_played[index] & mask) != 0 || LocalTime - clock.StartedAt < sound.delay) + continue; + _player.Sound($"{Definition.SfxFileNamePrefix}{sound.index:000}"); + _played[index] |= (byte)mask; + } + } + } + + public override void Render(Camera camera) + { + } + } +} diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectNode.cs.meta b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectNode.cs.meta new file mode 100644 index 000000000..76be86ca7 --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectNode.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 03437bbaa78854faca572e1b314a630a \ No newline at end of file diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectOverrides.cs b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectOverrides.cs new file mode 100644 index 000000000..543546b8a --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectOverrides.cs @@ -0,0 +1,133 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Pal3.Game.Effect.Original +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Core.DataReader.Data; + using Core.DataReader.Par; + using UnityEngine; + + [Serializable] + public sealed class OriginalEffectObjectOverride + { + public int GroupId, ObjectIndex; + public EffectDefinition Definition; + public EffectTask[] Tasks; + public bool Hidden; + } + + [Serializable] + public sealed class OriginalParticleCurveOverride + { + public string File, Parameter; + public bool Emitter; + public int Index; + public AnimationCurve[] Channels; + } + + [CreateAssetMenu(menuName = "PAL3/Original effect overrides")] + public sealed class OriginalEffectOverrides : ScriptableObject + { + public int Version = 1; + public List Objects = new(); + public List ParticleCurves = new(); + + public OriginalEffectObjectOverride Find(int group, int index) => + Objects.Find(o => o.GroupId == group && o.ObjectIndex == index); + + internal static bool Resolve(OriginalEffectOverrides edits, int group, int index, + ref EffectDefinition definition, ref EffectTask[] tasks) + { + if (edits != null && edits.Version != 1) + throw new NotSupportedException($"Unknown original effect override version {edits.Version}"); + OriginalEffectObjectOverride edit = edits?.Find(group, index); + if (edit?.Hidden == true) + return false; + if (edit != null) + { + definition = edit.Definition; + tasks = edit.Tasks ?? throw new InvalidOperationException("Missing effect override tasks"); + ValidateNumbers(new[] { definition.InitXPosition, definition.InitYPosition, definition.InitZPosition, + definition.InitXRotation, definition.InitYRotation, definition.InitZRotation, definition.InitSize, + definition.FramePerSecond, definition.RotationAngle, definition.Width, definition.DivisionCount, + definition.TopShrink, definition.BottomShrink, definition.TopRadius, definition.BottomRadius, + definition.TopRadiusIncreaseSpeed, definition.BottomRadiusIncreaseSpeed, definition.TopRiseSpeed, + definition.BottomRiseSpeed, definition.TextureRepeat }); + if (tasks.Length > 8) + throw new InvalidOperationException("An original effect object supports at most eight tasks"); + foreach (EffectTask t in tasks.Where(t => t != null && t.IsValid)) + { + ValidateNumbers(new[] { t.XStretchSpeed, t.SpeedX, t.SpeedY, t.SpeedZ, t.SpeedRot, + t.CircleAxisX, t.CircleAxisY, t.CircleAxisZ, t.CircleRadius, t.CircleSpeed, + t.RotAxisX, t.RotAxisY, t.RotAxisZ, t.RotSpeed, t.SpeedAlpha, t.SpeedRed, + t.SpeedGreen, t.SpeedBlue, t.StartTime, t.Duration, t.CreateTimeSlot, t.YStretchSpeed }); + if (t.Duration < 0) + throw new InvalidOperationException("An effect task duration cannot be negative"); + } + } + tasks = tasks.Where(t => t != null && t.IsValid).ToArray(); + return true; + } + + private static void ValidateNumbers(IEnumerable values) + { + if (values.Any(v => !float.IsFinite(v))) + throw new InvalidOperationException("Effect overrides require finite numeric parameters"); + } + + public bool HasParticleEdits(string file) => ParticleCurves.Exists(c => + string.Equals(OriginalEffectResources.Normalize(c.File), OriginalEffectResources.Normalize(file), StringComparison.OrdinalIgnoreCase)); + + public void ApplyParticle(string path, ParticleFile file) + { + foreach (OriginalParticleCurveOverride edit in ParticleCurves) + { + if (!string.Equals(OriginalEffectResources.Normalize(edit.File), OriginalEffectResources.Normalize(path), StringComparison.OrdinalIgnoreCase)) + continue; + ParticleCurve curve = FindCurve(file, edit.Emitter, edit.Index, edit.Parameter); + if (edit.Channels == null || edit.Channels.Length != curve.Channels || edit.Channels.Any(c => c == null)) + throw new InvalidOperationException("Particle curve channel count does not match original data"); + foreach (AnimationCurve channel in edit.Channels) + { + ValidateNumbers(channel.keys.SelectMany(k => new[] { k.time, k.value })); + // Infinite tangents are valid stepped keys. Evaluate the + // normalized interval to reject curves that produce NaN/Inf. + ValidateNumbers(Enumerable.Range(0, 101).Select(i => channel.Evaluate(i / 100f))); + } + curve.OverrideEvaluation = (time, channel) => channel >= 0 && channel < edit.Channels.Length ? edit.Channels[channel].Evaluate(time) : 0; + } + } + + public static ParticleCurve FindCurve(ParticleFile file, bool emitter, int index, string name) + { + if (emitter) + { + ParticleEmitter e = file.Emitters[index]; + return name switch + { + "Direction" => e.Direction, + "RandomVelocity" => e.RandomVelocity, + "Emission" => e.Emission, + "Life" => e.Life, + "Size" => e.Size, + "Rotation" => e.Rotation, + _ => throw new ArgumentException(name) + }; + } + ParticleDescription p = file.Particles[index]; + return name switch + { + "Size" => p.Size, + "Rotation" => p.Rotation, + "Alpha" => p.Alpha, + "Color" => p.Color, + _ => throw new ArgumentException(name) + }; + } + } +} diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectOverrides.cs.meta b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectOverrides.cs.meta new file mode 100644 index 000000000..9410eb8ce --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectOverrides.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3a0f5edeb37f04ad28e5e2ed9000e2c5 \ No newline at end of file diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectPlayer.cs b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectPlayer.cs new file mode 100644 index 000000000..a60a27df2 --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectPlayer.cs @@ -0,0 +1,296 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Pal3.Game.Effect.Original +{ + using System; + using System.Collections.Generic; + using Core.DataReader.Data; + using UnityEngine; + + // One simulation serves both clocks: real frame time in game, and fixed + // steps for editor playback/seeking. Rendering never advances the clock. + public sealed class OriginalEffectPlayer : MonoBehaviour + { + public const float FixedStep = 0.02f; + private readonly List _nodes = new(); + private OriginalEffectResources _resources; + private OriginalEffectOverrides _overrides; + private int _groupId; + private double _time; + private uint _seed; + private uint _spawnSequence; + private float _accumulator; + private Action _sound; + private bool _disposed, _suppressEvents; + private float _lastRepeat; + + public float Time => (float)_time; + public bool AutoAdvance { get; set; } = true; + + public bool Paused + { + get; set; + } + + public float Speed { get; set; } = 1; + + public bool Loop + { + get; set; + } + + public bool DestroyOnComplete + { + get; set; + } + + public float RepeatInterval + { + get; set; + } + + public int NodeCount => _nodes.Count; + + public int QuadCount + { + get; private set; + } + + public bool IsAlive + { + get; private set; + } + + public string Error + { + get; private set; + } + + public void Initialize(OriginalEffectResources resources, int groupId, uint seed = 1, + OriginalEffectOverrides overrides = null, Action sound = null) + { + if (overrides != null && overrides.Version != 1) + throw new NotSupportedException($"Unknown original effect override version {overrides.Version}"); + _resources = resources; + _groupId = groupId; + _seed = seed == 0 ? 1 : seed; + _overrides = overrides; + _sound = sound; + ResetSimulation(); + } + + private void ResetSimulation(bool resetRandom = true) + { + ClearNodes(); + _time = 0; + _accumulator = 0; + _lastRepeat = 0; + Error = null; + if (resetRandom) + _spawnSequence = 0; + try + { + AddGroup(_groupId, Vector3.zero); + IsAlive = _nodes.Count > 0; + } + catch (Exception ex) + { + Error = ex.Message; + ClearNodes(); + IsAlive = false; + } + } + + internal void AddGroup(int groupId, Vector3 position) + { + if (groupId < 0 || groupId >= _resources.Definitions.Groups.Length) + throw new ArgumentOutOfRangeException(nameof(groupId)); + EffectGroup group = _resources.Definitions.Groups[groupId]; + for (int i = 0; i < group.Objects.Length; i++) + { + EffectGroupObject obj = group.Objects[i]; + _resources.Definitions.TryGetType(obj.TypeCrc, out EffectDefinition d); + EffectTask[] tasks = obj.Tasks; + if (!OriginalEffectOverrides.Resolve(_overrides, groupId, i, ref d, ref tasks)) + continue; + if (_nodes.Count >= 2048) + throw new InvalidOperationException("Original effect object limit (2048) reached"); + obj = new EffectGroupObject { TypeCrc = obj.TypeCrc, Tasks = tasks }; + var root = new GameObject($"{groupId}:{i} {d.TypeName}") { hideFlags = HideFlags.DontSave, layer = gameObject.layer }; + root.transform.SetParent(transform, false); + OriginalEffectNode node = null; + try + { + string kind = EffectClass.Name(d.ClassNameCrc); + uint seed = _seed + _spawnSequence++ * 7919 + (uint)(groupId * 104729 + i); + node = kind switch + { + "callback" or "audio" => new OriginalControlNode(root, d, obj.Tasks, position, Time, this, kind), + "quad" => new OriginalQuadNode(root, d, obj.Tasks, position, Time, _resources), + "S-Particle" => new OriginalSparNode(root, d, obj.Tasks, position, Time, _resources, seed), + "particle" => new OriginalParticleNode(root, d, obj.Tasks, position, Time, _resources, seed, _overrides), + "cylinder" or "grid" => new OriginalGeometryNode(root, d, obj.Tasks, position, Time, _resources, kind), + "meteor" or "shock" => new OriginalTrailNode(root, d, obj.Tasks, position, Time, _resources, kind, seed), + "model" => new OriginalModelNode(root, d, obj.Tasks, position, Time, _resources), + "explosive" => new OriginalExplosiveNode(root, d, obj.Tasks, position, Time, _resources, seed), + _ => throw new NotSupportedException($"Original effect class {kind}: {d.TypeName}"), + }; + _nodes.Add(node); + } + catch + { + node?.Dispose(); + OriginalEffectResources.Destroy(root); + throw; + } + } + } + + internal void Sound(string name) + { + if (!_suppressEvents) + _sound?.Invoke(name); + } + + public void Advance(float delta) + { + float elapsed = ScaledDelta(delta); + if (elapsed <= 0) + return; + _accumulator += elapsed; + while (_accumulator + 0.000001f >= FixedStep) + { + _accumulator -= FixedStep; + if (!SimulateStep(FixedStep)) + break; + } + } + + public void AdvanceFrame(float delta) + { + float elapsed = ScaledDelta(delta); + if (elapsed <= 0) + return; + elapsed += _accumulator; + _accumulator = 0; + SimulateStep(elapsed); + } + + private float ScaledDelta(float delta) + { + if (_disposed || Paused || Error != null || delta <= 0 || !float.IsFinite(delta)) + return 0; + float elapsed = delta * (float.IsFinite(Speed) ? Mathf.Clamp(Speed, 0, 16) : 1); + return float.IsFinite(elapsed) ? elapsed : 0; + } + + private bool SimulateStep(float delta) + { + _time += delta; + try + { + int count = _nodes.Count; + bool alive = false; + for (int i = 0; i < count; i++) + { + _nodes[i].Step(Time, delta); + alive |= _nodes[i].Alive; + } + IsAlive = alive || _nodes.Count > count; + for (int i = _nodes.Count - 1; i >= 0; i--) + if (!_nodes[i].Alive) + { + _nodes[i].Dispose(); + _nodes.RemoveAt(i); + } + if (Loop && RepeatInterval > 0 && Time - _lastRepeat >= RepeatInterval) + { + AddGroup(_groupId, Vector3.zero); + _lastRepeat = Time; + IsAlive = true; + } + if (Loop && !IsAlive && RepeatInterval <= 0) + { + ResetSimulation(false); + return false; + } + return true; + } + catch (Exception ex) + { + Error = ex.ToString(); + ClearNodes(); + IsAlive = false; + Debug.LogException(ex); + return false; + } + } + + public void Seek(float time) + { + if (_disposed) + return; + time = Mathf.Clamp(float.IsFinite(time) ? time : 0, 0, 600); + bool paused = Paused, loop = Loop; + float speed = Speed; + ResetSimulation(); + Paused = false; + Loop = false; + Speed = 1; + _suppressEvents = true; + try + { + Advance(time); + } + finally + { + _suppressEvents = false; + Paused = paused; + Loop = loop; + Speed = speed; + } + } + + public void Render(Camera camera) + { + QuadCount = 0; + foreach (OriginalEffectNode node in _nodes) + { + node.Render(camera); + QuadCount += node.QuadCount; + } + } + + private void Update() + { + if (!AutoAdvance) + return; + AdvanceFrame(UnityEngine.Time.deltaTime); + if (DestroyOnComplete && !Loop && !IsAlive && Time > 0) + OriginalEffectResources.Destroy(gameObject); + } + + private void LateUpdate() + { + if (AutoAdvance) + Render(Camera.main); + } + + private void ClearNodes() + { + foreach (OriginalEffectNode node in _nodes) + node.Dispose(); + _nodes.Clear(); + QuadCount = 0; + } + + private void OnDestroy() + { + _disposed = true; + ClearNodes(); + } + } +} diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectPlayer.cs.meta b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectPlayer.cs.meta new file mode 100644 index 000000000..88f1fdd24 --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectPlayer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7b4e14dc881ee4e348d1125f142c9dc4 \ No newline at end of file diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectResources.cs b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectResources.cs new file mode 100644 index 000000000..26f6340b6 --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectResources.cs @@ -0,0 +1,463 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Pal3.Game.Effect.Original +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using Core.DataReader; + using Core.DataReader.Data; + using Core.DataReader.Cvd; + using Core.DataReader.Par; + using Core.DataReader.Pol; + using Core.FileSystem; + using Data; + using Engine.Core.Implementation; + using Engine.Core.Abstraction; + using UnityEngine; + using UnityEngine.Rendering; + + public sealed class OriginalEffectResources : IDisposable + { + private readonly ICpkFileSystem _fileSystem; + private readonly int _codepage; + private readonly Dictionary _textures = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _particles = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _particleBytes = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _overrideParticles = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _shapes = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _polModels = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _sparFiles = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _models = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary<(string, int, int, bool, int, int), Material> _materials = new(); + private readonly Dictionary _diagnostics = new(); + private readonly Dictionary _overrideDiagnostics = new(); + private OriginalEffectOverrides _cachedOverrides; + private string _cachedOverridesJson; + private EffectDefinitionFile _definitions; + + public EffectDefinitionFile Definitions => _definitions ??= Read("cbdata\\flexeff.data", new EffectDefinitionFileReader()); + + public string GameVariant => Definitions.EffectDefinitions.Length == 2048 ? "PAL3A" : "PAL3"; + + public string OverridesPath => $"Effects/{GameVariant}Overrides"; + + public OriginalEffectResources(ICpkFileSystem fileSystem, int codepage = 936) + { + _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + _codepage = codepage; + } + + public static string Normalize(string path) + { + path = path.Replace('/', '\\'); + return path.StartsWith("basedata.cpk\\", StringComparison.OrdinalIgnoreCase) ? path : "basedata.cpk\\" + path; + } + + public byte[] Bytes(string path) => _fileSystem.ReadAllBytes(Normalize(path)); + + public T Read(string path, IFileReader reader) => reader.Read(Bytes(path), _codepage); + + public ITextureResourceProvider ModelTextureProvider(string file) => new ModelTextures(this, file); + + private sealed class ModelTextures : ITextureResourceProvider + { + private readonly OriginalEffectResources _owner; + private readonly string _file; + + public ModelTextures(OriginalEffectResources owner, string file) + { + _owner = owner; + _file = file; + } + + public string GetTexturePath(string name) => RelativeTo(_file, name); + + public ITexture2D GetTexture(string name) => GetTexture(name, out _); + + public ITexture2D GetTexture(string name, out bool hasAlphaChannel) + { + hasAlphaChannel = true; + return new UnityTexture2D(_owner.Texture(GetTexturePath(name))); + } + } + + public ParticleFile Particle(string path, OriginalEffectOverrides edits = null) + { + path = Normalize(path); + if (!_particles.TryGetValue(path, out ParticleFile file)) + { + if (!_particleBytes.TryGetValue(path, out byte[] bytes)) + _particleBytes[path] = bytes = Bytes(path); + _particles[path] = file = ((IFileReader)new ParticleFileReader()).Read(bytes, _codepage); + } + if (edits != null && edits.HasParticleEdits(path)) + { + RefreshOverrideCache(edits); + if (!_overrideParticles.TryGetValue(path, out ParticleFile copy)) + { + copy = ((IFileReader)new ParticleFileReader()).Read(_particleBytes[path], _codepage); + edits.ApplyParticle(path, copy); + _overrideParticles[path] = copy; + } + return copy; + } + return file; + } + + private void RefreshOverrideCache(OriginalEffectOverrides edits) + { + // Public override fields also support scripts, Undo and the native + // Inspector. A content stamp catches those edits without relying on + // every caller to manually invalidate a revision counter. + string json = JsonUtility.ToJson(edits); + if (ReferenceEquals(edits, _cachedOverrides) && json == _cachedOverridesJson) + return; + _cachedOverrides = edits; + _cachedOverridesJson = json; + _overrideDiagnostics.Clear(); + _overrideParticles.Clear(); + } + + public ParticleShapeFile Shape(string path) + { + if (!_shapes.TryGetValue(path, out ParticleShapeFile file)) + _shapes.Add(path, file = Read(path, new ParticleShapeFileReader())); + return file; + } + + public SparFile Spar(string path) + { + if (!_sparFiles.TryGetValue(path, out SparFile file)) + _sparFiles.Add(path, file = Read(path, new SparFileReader())); + return file; + } + + public PolFile PolModel(string path) + { + if (!_polModels.TryGetValue(path, out PolFile file)) + _polModels.Add(path, file = Read(path, new PolFileReader())); + return file; + } + + public CvdFile Model(string path) + { + if (!_models.TryGetValue(path, out CvdFile file)) + _models.Add(path, file = Read(path, new CvdFileReader())); + return file; + } + + public static string RelativeTo(string file, string relative) + { + file = file.Replace('/', '\\'); + int slash = file.LastIndexOf('\\'); + return (slash < 0 ? "" : file[..(slash + 1)]) + relative.Replace('/', '\\'); + } + + public string ResolveTexture(string path) + { + path = Normalize(path); + string dds = Path.ChangeExtension(path, ".dds"); + if (_fileSystem.FileExists(dds)) + return dds; + if (_fileSystem.FileExists(path)) + return path; + throw new FileNotFoundException($"Original effect texture not found: {path}", path); + } + + public Texture2D Texture(string path) + { + path = ResolveTexture(path); + if (_textures.TryGetValue(path, out Texture2D cached)) + return cached; + Texture2D texture; + if (path.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) + { + texture = new Texture2D(2, 2, TextureFormat.RGBA32, false); + if (!texture.LoadImage(Bytes(path), true)) + { + Destroy(texture); + throw new InvalidDataException($"Invalid image {path}"); + } + } + else + { + var provider = new TextureProvider(_fileSystem, new TextureLoaderFactory(), new UnityTextureFactory(), + "basedata.cpk", usage: TextureUsage.Sprite); + texture = provider.GetTexture(path["basedata.cpk\\".Length..])?.NativeObject as Texture2D; + if (texture == null) + throw new InvalidDataException($"Could not load original effect texture {path}"); + } + texture.name = path; + texture.wrapMode = TextureWrapMode.Repeat; + texture.hideFlags = HideFlags.DontSave; + _textures.Add(path, texture); + return texture; + } + + public Material Material(string texture, bool additive, bool noZ, int alphaMode = 0) => + RenderMaterial(texture, alphaMode == 1 ? BlendMode.One : BlendMode.SrcAlpha, + additive ? BlendMode.One : BlendMode.OneMinusSrcAlpha, noZ, alphaMode, CullMode.Off); + + // gfxscript/Particle{1,2,3,NotBlend}.gbf: both color and alpha modulate. + public Material SparMaterial(string texture, int shader) => shader switch + { + 1 => RenderMaterial(texture, BlendMode.SrcAlpha, BlendMode.DstAlpha, false, 0, CullMode.Back), + 2 => RenderMaterial(texture, BlendMode.SrcAlpha, BlendMode.OneMinusSrcColor, false, 0, CullMode.Back), + 3 => RenderMaterial(texture, BlendMode.SrcColor, BlendMode.OneMinusSrcColor, false, 0, CullMode.Back), + _ => RenderMaterial(texture, BlendMode.One, BlendMode.Zero, false, 0, CullMode.Back) + }; + + private Material RenderMaterial(string texture, BlendMode source, BlendMode destination, bool noZ, int alphaMode, CullMode cull) + { + var key = (texture, (int)source, (int)destination, noZ, alphaMode, (int)cull); + if (_materials.TryGetValue(key, out Material cached)) + return cached; + Shader shader = Shader.Find("Pal3/OriginalEffect"); + if (shader == null || !shader.isSupported) + throw new InvalidOperationException("OriginalEffect URP shader is unavailable"); + var material = new Material(shader) { name = $"Original {texture}", hideFlags = HideFlags.DontSave }; + try + { + material.mainTexture = string.IsNullOrEmpty(texture) ? Texture2D.whiteTexture : Texture(texture); + } + catch + { + Destroy(material); + throw; + } + material.SetInt("_SrcBlend", (int)source); + material.SetInt("_DstBlend", (int)destination); + material.SetInt("_Cull", (int)cull); + material.SetInt("_ZTest", (int)(noZ ? CompareFunction.Always : CompareFunction.LessEqual)); + material.SetFloat("_AlphaMode", alphaMode); + _materials.Add(key, material); + return material; + } + + // Validate the complete callback closure before choosing this backend. + public string[] Diagnose(int groupId, OriginalEffectOverrides edits = null) + { + if (edits != null) + RefreshOverrideCache(edits); + Dictionary cache = edits == null ? _diagnostics : _overrideDiagnostics; + if (cache.TryGetValue(groupId, out string[] cached)) + return cached; + var errors = new List(); + var visiting = new HashSet(); + var done = new HashSet(); + void ModelTextures(string path, CvdGeometryNode node) + { + if (node.IsGeometryNode) + foreach (CvdMeshSection section in node.Mesh.MeshSections) + { + string name = section.Material.TextureFileNames[0]; + if (!string.IsNullOrEmpty(name)) + ResolveTexture(RelativeTo(path, name)); + } + foreach (CvdGeometryNode child in node.Children) + ModelTextures(path, child); + } + void Visit(int id) + { + if (done.Contains(id)) + return; + if (!visiting.Add(id)) + { + errors.Add($"Recursive callback cycle at group {id}"); + return; + } + if (id < 0 || id >= Definitions.Groups.Length) + { + errors.Add($"Group {id} is outside flexeff"); + visiting.Remove(id); + return; + } + EffectGroupObject[] objects = Definitions.Groups[id].Objects; + for (int index = 0; index < objects.Length; index++) + { + EffectGroupObject obj = objects[index]; + Definitions.TryGetType(obj.TypeCrc, out EffectDefinition d); + try + { + EffectTask[] tasks = obj.Tasks; + if (!OriginalEffectOverrides.Resolve(edits, id, index, ref d, ref tasks)) + continue; + string kind = EffectClass.Name(d.ClassNameCrc); + switch (kind) + { + case "callback": + foreach (EffectTask t in tasks) + foreach (var child in t.Callbacks()) + Visit(child.groupId); + break; + case "audio": + break; + case "S-Particle": + SparFile spar = Spar(d.ParticleFileName); + foreach (SparGroup sg in spar.Groups) + { + OriginalSparEmitter.Validate(sg); + ResolveTexture(RelativeTo(d.ParticleFileName, sg.Texture)); + } + break; + case "particle": + ParticleFile p = Particle(d.ParticleFileName, edits); + foreach (ParticleDescription pd in p.Particles) + ResolveTexture(RelativeTo(d.ParticleFileName, pd.Texture)); + foreach (ParticleEmitter e in p.Emitters) + if (e.Shape == 2) + Shape(RelativeTo(d.ParticleFileName, e.ShapePath)); + break; + case "quad": + case "cylinder": + case "meteor": + case "shock": + case "grid": + case "explosive": + ResolveTexture(d.TextureName); + break; + case "model": + if (d.TextureName.EndsWith(".pol", StringComparison.OrdinalIgnoreCase)) + { + foreach (PolMesh mesh in PolModel(d.TextureName).Meshes) + foreach (PolTexture texture in mesh.Textures) + foreach (string name in texture.Material.TextureFileNames) + if (!string.IsNullOrEmpty(name)) + ResolveTexture(RelativeTo(d.TextureName, name)); + } + else if (d.TextureName.EndsWith(".cvd", StringComparison.OrdinalIgnoreCase)) + { + foreach (CvdGeometryNode node in Model(d.TextureName).RootNodes) + ModelTextures(d.TextureName, node); + } + else + throw new NotSupportedException($"Unknown original model format: {d.TextureName}"); + break; + default: + errors.Add($"Group {id}, {d.TypeName}: unsupported original class {kind}"); + break; + } + } + catch (Exception ex) + { + errors.Add($"Group {id}, {d.TypeName}: {ex.Message}"); + } + } + visiting.Remove(id); + done.Add(id); + } + Visit(groupId); + string[] result = errors.ToArray(); + cache[groupId] = result; + return result; + } + + public IEnumerable Preload(int groupId, OriginalEffectOverrides edits = null) + { + var seen = new HashSet(); + var pending = new Stack(); + pending.Push(groupId); + while (pending.Count > 0) + { + int id = pending.Pop(); + if (!seen.Add(id)) + continue; + EffectGroupObject[] objects = Definitions.Groups[id].Objects; + for (int index = 0; index < objects.Length; index++) + { + EffectGroupObject obj = objects[index]; + Definitions.TryGetType(obj.TypeCrc, out EffectDefinition d); + EffectTask[] tasks = obj.Tasks; + if (!OriginalEffectOverrides.Resolve(edits, id, index, ref d, ref tasks)) + continue; + string kind = EffectClass.Name(d.ClassNameCrc); + if (kind == "callback") + foreach (EffectTask t in tasks) + foreach (var child in t.Callbacks()) + pending.Push(child.groupId); + else if (kind == "particle") + foreach (ParticleDescription p in Particle(d.ParticleFileName, edits).Particles) + Texture(RelativeTo(d.ParticleFileName, p.Texture)); + else if (kind == "S-Particle") + foreach (SparGroup group in Spar(d.ParticleFileName).Groups) + Texture(RelativeTo(d.ParticleFileName, group.Texture)); + else if (kind != "audio" && kind != "model") + Texture(d.TextureName); + yield return id; + } + } + } + + public void TrimUnusedRenderAssets(IEnumerable liveRoots) + { + var materials = new HashSet(); + var textures = new HashSet(); + foreach (GameObject root in liveRoots) + { + if (root == null) + continue; + foreach (Renderer renderer in root.GetComponentsInChildren(true)) + foreach (Material material in renderer.sharedMaterials) + { + if (material == null) + continue; + materials.Add(material); + // POL materials can also hold a baked lightmap. Every + // referenced texture must survive a scene-cache trim. + foreach (int property in material.GetTexturePropertyNameIDs()) + { + Texture texture = material.GetTexture(property); + if (texture != null) + textures.Add(texture); + } + } + } + foreach (var key in _materials.Where(p => !materials.Contains(p.Value)).Select(p => p.Key).ToArray()) + { + Destroy(_materials[key]); + _materials.Remove(key); + } + foreach (string key in _textures.Where(p => !textures.Contains(p.Value)).Select(p => p.Key).ToArray()) + { + Destroy(_textures[key]); + _textures.Remove(key); + } + } + + public void Dispose() + { + foreach (Material m in _materials.Values) + Destroy(m); + foreach (Texture2D t in _textures.Values) + Destroy(t); + _materials.Clear(); + _textures.Clear(); + _particles.Clear(); + _particleBytes.Clear(); + _overrideParticles.Clear(); + _shapes.Clear(); + _models.Clear(); + _polModels.Clear(); + _sparFiles.Clear(); + _diagnostics.Clear(); + _overrideDiagnostics.Clear(); + _cachedOverrides = null; + _cachedOverridesJson = null; + } + + public static void Destroy(UnityEngine.Object value) + { + if (value == null) + return; + if (Application.isPlaying) + UnityEngine.Object.Destroy(value); + else + UnityEngine.Object.DestroyImmediate(value); + } + } +} diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectResources.cs.meta b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectResources.cs.meta new file mode 100644 index 000000000..0bce0cfe0 --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectResources.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6df74732b3a2d44b69e9ed674b10f621 \ No newline at end of file diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectViewer.cs b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectViewer.cs new file mode 100644 index 000000000..60cfa38dd --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectViewer.cs @@ -0,0 +1,168 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Pal3.Game.Effect.Original +{ + using System; + using UnityEngine; + + // Runtime controls for ResourceViewer; editor tooling uses the same player. + public sealed class OriginalEffectViewer : MonoBehaviour + { + private static int _nextWindowId = 25000; + private readonly int _windowId = _nextWindowId++; + private OriginalEffectResources _resources; + private Transform _parent; + private Action _beforeLoad; + private OriginalEffectPlayer _player; + private OriginalPortalEffect _portal; + + public uint PortalType + { + get; set; + } + + private GameObject _root; + private string _id = "98", _diagnostics = ""; + private Rect _window = new(15, 65, 330, 250); + private bool _visible; + + public void Initialize(OriginalEffectResources resources, Transform parent, Action beforeLoad) + { + _resources = resources; + _parent = parent; + _beforeLoad = beforeLoad; + } + + public void Load(int groupId) + { + Stop(); + _beforeLoad?.Invoke(); + _id = groupId.ToString(); + _visible = true; + _diagnostics = ""; + try + { + if (groupId < -1) + throw new ArgumentOutOfRangeException(nameof(groupId)); + OriginalEffectOverrides edits = Resources.Load(_resources.OverridesPath); + if (groupId >= 0) + { + _diagnostics = string.Join("\n", _resources.Diagnose(groupId, edits)); + if (_diagnostics.Length > 0) + return; + } + _root = new GameObject(groupId < 0 ? "Original Transport" : $"Original {_resources.GameVariant} group {groupId}"); + _root.transform.SetParent(_parent, false); + if (groupId < 0) + { + _portal = _root.AddComponent(); + _portal.Initialize(_resources, PortalType); + } + else + { + _player = _root.AddComponent(); + _player.Initialize(_resources, groupId, overrides: edits); + _diagnostics += _player.Error; + } + } + catch (Exception ex) + { + _diagnostics = ex.Message; + Stop(); + } + } + + public void Stop() + { + if (_root != null) + { + _root.SetActive(false); + OriginalEffectResources.Destroy(_root); + } + _root = null; + _player = null; + _portal = null; + } + + public void Seek(float time) + { + time = Mathf.Clamp(float.IsFinite(time) ? time : 0, 0, 600); + if (_player != null) + _player.Seek(time); + if (_portal != null) + _portal.Sample(time); + } + + public void Pause(bool value) + { + if (_player != null) + _player.Paused = value; + if (_portal != null) + _portal.AutoAdvance = !value; + } + + private void OnDisable() => Stop(); + + private void OnGUI() + { + if (_resources == null) + return; + if (GUI.Button(new Rect(Screen.width - 145, 15, 130, 32), $"Original {_resources.GameVariant} effects")) + _visible = !_visible; + if (_visible) + _window = GUI.Window(_windowId, _window, Draw, $"Original {_resources.GameVariant} effect viewer"); + } + + private void Draw(int id) + { + GUILayout.BeginHorizontal(); + GUILayout.Label("Group", GUILayout.Width(45)); + _id = GUILayout.TextField(_id); + if (GUILayout.Button("Load") && int.TryParse(_id, out int group)) + Load(group); + if (GUILayout.Button("Transport")) + Load(-1); + GUILayout.EndHorizontal(); + if (_portal != null && _resources.GameVariant == "PAL3A") + { + GUILayout.BeginHorizontal(); + GUILayout.Label($"Transport style {PortalType}"); + if (GUILayout.Button("Next style")) + { + PortalType = (PortalType + 1) % 7; + Load(-1); + } + GUILayout.EndHorizontal(); + } + GUILayout.BeginHorizontal(); + if (GUILayout.Button("Play")) + Pause(false); + if (GUILayout.Button("Pause")) + Pause(true); + if (GUILayout.Button("Reset")) + Seek(0); + if (GUILayout.Button("Stop")) + Stop(); + GUILayout.EndHorizontal(); + if (_player != null) + { + _player.Speed = GUILayout.HorizontalSlider(_player.Speed, 0, 4); + GUILayout.Label($"Speed {_player.Speed:0.00}x"); + float time = GUILayout.HorizontalSlider(_player.Time, 0, Mathf.Max(30, _player.Time)); + if (Mathf.Abs(time - _player.Time) > 0.001f) + { + Pause(true); + Seek(time); + } + GUILayout.Label($"{_player.Time:0.00}s objects {_player.NodeCount} quads/particles {_player.QuadCount}"); + if (_player.Error != null) + GUILayout.Label(_player.Error); + } + GUILayout.Label(_diagnostics.Length == 0 ? "Original data / shared runtime" : _diagnostics); + GUI.DragWindow(); + } + } +} diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectViewer.cs.meta b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectViewer.cs.meta new file mode 100644 index 000000000..e75d36264 --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalEffectViewer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a67dbb1e7d0e340fe8b5dc4ca7bbecb5 \ No newline at end of file diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/OriginalExplosiveNode.cs b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalExplosiveNode.cs new file mode 100644 index 000000000..75d69518e --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalExplosiveNode.cs @@ -0,0 +1,129 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Pal3.Game.Effect.Original +{ + using System; + using Core.DataReader.Data; + using UnityEngine; + + // CEffExplosive: subdivided plane, per-fragment velocity and spin. + internal sealed class OriginalExplosiveNode : OriginalEffectNode + { + private readonly EffectMeshBuffer _mesh; + private readonly Vector3[][] _corners; + private readonly Vector3[] _velocity, _spin; + private readonly bool[] _exploded; + private readonly System.Random _random; + private readonly int _columns, _rows; + + public override int QuadCount => _mesh.QuadCount; + + public OriginalExplosiveNode(GameObject root, EffectDefinition d, EffectTask[] tasks, Vector3 origin, float birth, OriginalEffectResources resources, uint seed) + : base(root, d, tasks, origin, birth) + { + _columns = (int)d.RotationAngle; + _rows = (int)d.DivisionCount; + if (_columns < 1 || _rows < 1 || (long)_columns * _rows >= 2501) + throw new InvalidOperationException("Invalid original explosive subdivisions"); + _mesh = new EffectMeshBuffer(root, resources.Material(d.TextureName, d.IsAdditiveBlend, d.IsZTestIgnored)); + _random = new System.Random(unchecked((int)seed)); + _corners = new Vector3[_columns * _rows][]; + _velocity = new Vector3[_corners.Length]; + _spin = new Vector3[_corners.Length]; + _exploded = new bool[Tasks.Length]; + for (int row = 0; row < _rows; row++) + for (int column = 0; column < _columns; column++) + { + float x = (column - _columns / 2f) * d.Width; + float z = (_rows / 2f - row) * d.Width; + _corners[row * _columns + column] = new[] + { + new Vector3(x, 0, z), new Vector3(x + d.Width, 0, z), + new Vector3(x + d.Width, 0, z - d.Width), new Vector3(x, 0, z - d.Width) + }; + } + } + + private void Explode() + { + int maximum = Mathf.RoundToInt(Definition.TopRadius); + if (maximum <= 0) + maximum = 2; + float minimum = Mathf.Max(0, Definition.BottomRadius); + int count = 0; + // Preserve the original column/row traversal, including its center indices. + for (int column = 0; column < _columns; column++) + for (int row = 0; row < _rows; row++, count++) + { + int power = Mathf.Max(1, Mathf.RoundToInt(Mathf.Max(minimum, maximum - + Mathf.Abs(row - _columns / 2) * Mathf.Abs(column - _rows / 2) * Mathf.Max(0, Definition.TopRiseSpeed)))); + float Component() => _random.Next(power) - power / 2; + Vector3 velocity = new(Component(), Component(), Component()); + if (velocity.magnitude < minimum) + velocity = velocity.normalized * minimum; + _velocity[count] = velocity + new Vector3(Definition.TopRadiusIncreaseSpeed, Definition.BottomRadiusIncreaseSpeed, Definition.TextureRepeat); + float Spin() => (_random.Next(160) - 80) * 16f; + _spin[count] = new Vector3(Spin(), Spin(), Spin()); + } + } + + protected override void Apply(EffectTask t, OriginalTaskClock clock, float dt) + { + Position += new Vector3(t.SpeedX, t.SpeedY, t.SpeedZ) * dt; + Rotation += new Vector3(t.RotAxisX, t.RotAxisY, t.RotAxisZ) * dt; + Scale += new Vector3(t.XStretchSpeed, 0, t.CreateTimeSlot) * dt; + Tint.a = Mathf.Clamp01(Tint.a + t.SpeedAlpha * dt / 255); + Orbit = OrbitPosition(new Vector3(t.CircleAxisX, t.CircleAxisY, t.CircleAxisZ), (LocalTime - clock.StartedAt) * t.CircleSpeed, t.CircleRadius); + int index = Array.IndexOf(Clocks, clock); + if (LocalTime - clock.StartedAt <= t.SpeedRed) + return; + if (!_exploded[index]) + { + Explode(); + _exploded[index] = true; + } + for (int i = 0; i < _corners.Length; i++) + { + Vector3[] corners = _corners[i]; + Vector3 center = (corners[0] + corners[1] + corners[2] + corners[3]) / 4; + Vector3 moved = center + _velocity[i] * dt; + Quaternion rotation = Quaternion.AngleAxis(_spin[i].y * dt, Vector3.right) * + Quaternion.AngleAxis(_spin[i].x * dt, Vector3.up) * Quaternion.AngleAxis(_spin[i].z * dt, Vector3.forward); + for (int corner = 0; corner < 4; corner++) + corners[corner] = moved + rotation * (corners[corner] - center); + } + } + + public override void Render(Camera camera) + { + _mesh.Clear(); + if (Alive && Visible) + { + var atlas = Atlas(LocalTime); + Vector2 step = (atlas.max - atlas.min) / new Vector2(_columns, _rows); + Quaternion rotation = RawRotation(Rotation); + for (int i = 0; i < _corners.Length; i++) + { + Vector3 P(int corner) + { + Vector3 v = rotation * _corners[i][corner]; + v = Vector3.Scale(v, new Vector3(Scale.x, 1, Scale.z)); + return UnityPosition(Position + Orbit + v); + } + Vector2 uv = atlas.min + Vector2.Scale(new Vector2(i % _columns, i / _columns), step); + _mesh.Quad(P(0), P(1), P(2), P(3), uv, uv + step, Tint); + } + } + _mesh.Upload(); + } + + public override void Dispose() + { + _mesh.Dispose(); + base.Dispose(); + } + } +} diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/OriginalExplosiveNode.cs.meta b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalExplosiveNode.cs.meta new file mode 100644 index 000000000..c00784b19 --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalExplosiveNode.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 99a3ca8ca9c646d4b5357809a1f6749d diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/OriginalGeometryNode.cs b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalGeometryNode.cs new file mode 100644 index 000000000..84dc19428 --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalGeometryNode.cs @@ -0,0 +1,128 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Pal3.Game.Effect.Original +{ + using Core.DataReader.Data; + using UnityEngine; + + internal sealed class OriginalGeometryNode : OriginalEffectNode + { + private readonly EffectMeshBuffer _mesh; + private readonly string _kind; + private readonly Vector3[] _points; + private readonly int _columns, _rows; + + public override int QuadCount => _mesh.QuadCount; + + public OriginalGeometryNode(GameObject root, EffectDefinition d, EffectTask[] tasks, Vector3 origin, float birth, OriginalEffectResources resources, string kind) + : base(root, d, tasks, origin, birth) + { + _kind = kind; + Scale = new Vector3(d.InitSize, 1, d.InitSize); + _mesh = new EffectMeshBuffer(root, resources.Material(d.TextureName, d.IsAdditiveBlend, d.IsZTestIgnored)); + if (kind == "cylinder") + { + _columns = Mathf.Clamp(Mathf.RoundToInt(d.DivisionCount), 1, 4096); + _rows = 1; + _points = new Vector3[(_columns + 1) * 2]; + for (int i = 0; i <= _columns; i++) + { + float a = i * d.RotationAngle / _columns; + float top = d.TopRadius + d.TopRadiusIncreaseSpeed * a - (i % 2 == 1 ? d.TopShrink : 0); + float bottom = d.BottomRadius + d.BottomRadiusIncreaseSpeed * a - (i % 2 == 1 ? d.BottomShrink : 0); + float rad = a * Mathf.Deg2Rad; + _points[i * 2] = new Vector3(Mathf.Sin(rad) * bottom, d.BottomRiseSpeed * a, Mathf.Cos(rad) * bottom); + _points[i * 2 + 1] = new Vector3(Mathf.Sin(rad) * top, d.Width + d.TopRiseSpeed * a, Mathf.Cos(rad) * top); + } + } + else + { + _columns = Mathf.Clamp(Mathf.RoundToInt(d.RotationAngle), 1, 128); + _rows = Mathf.Clamp(Mathf.RoundToInt(d.DivisionCount), 1, 128); + _points = new Vector3[(_columns + 1) * (_rows + 1)]; + for (int z = 0; z <= _rows; z++) + for (int x = 0; x <= _columns; x++) + _points[z * (_columns + 1) + x] = new Vector3((x - _columns * 0.5f) * d.Width, 0, (_rows * 0.5f - z) * d.Width); + } + } + + protected override void Apply(EffectTask t, OriginalTaskClock clock, float dt) + { + base.Apply(t, clock, dt); + if (_kind == "cylinder") + { + Scale.z = Scale.x; + // The union at CreateTimeSlot is UV speed for cylinders, not + // Z stretch. GameBox resets a positive V offset after one turn. + if (TextureOffset > 1) + TextureOffset = 0; + } + else + { + // Grid Z stretch shares CreateTimeSlot; YStretchSpeed is not + // used by CEffGrid (height comes from its flap calculation). + Scale.y = 1; + } + } + + public override void Render(Camera camera) + { + _mesh.Clear(); + if (!Alive || !Visible) + { + _mesh.Upload(); + return; + } + var uv = Atlas(LocalTime); + Quaternion rot = RawRotation(Rotation); + Vector3 P(int index) + { + Vector3 p = _points[index]; + if (_kind == "grid") + { + float x = index % (_columns + 1), z = index / (_columns + 1); + float sin = Mathf.Sin((LocalTime + x + 1) * Definition.BottomRadius + z + 1); + float cos = Mathf.Cos((LocalTime + z + 1) * Definition.BottomRadius + x + 1); + p.x += (sin - cos) * Definition.TopRadius; + p.z += (cos + sin) * Definition.TopRadius; + p.y += Mathf.Sin(LocalTime * Definition.TopRadiusIncreaseSpeed + x) * Definition.TextureRepeat; + } + return UnityPosition(Position + Orbit + rot * Vector3.Scale(p, Scale)); + } + if (_kind == "cylinder") + { + float repeat = Definition.TextureRepeat; + for (int i = 0; i < _columns; i++) + { + float u0 = uv.min.x + (uv.max.x - uv.min.x) * repeat * i / _columns; + float u1 = uv.min.x + (uv.max.x - uv.min.x) * repeat * (i + 1) / _columns; + _mesh.Quad(P(i * 2), P(i * 2 + 2), P(i * 2 + 3), P(i * 2 + 1), + new Vector2(u0, uv.min.y + TextureOffset), new Vector2(u1, uv.max.y + TextureOffset), Tint); + } + } + else + { + for (int z = 0; z < _rows; z++) + for (int x = 0; x < _columns; x++) + { + int i = z * (_columns + 1) + x; + Vector2 a = Vector2.Lerp(uv.min, uv.max, 0); + a.x = Mathf.Lerp(uv.min.x, uv.max.x, (float)x / _columns); + a.y = Mathf.Lerp(uv.min.y, uv.max.y, (float)z / _rows); + Vector2 b = new(Mathf.Lerp(uv.min.x, uv.max.x, (float)(x + 1) / _columns), Mathf.Lerp(uv.min.y, uv.max.y, (float)(z + 1) / _rows)); + _mesh.Quad(P(i), P(i + 1), P(i + _columns + 2), P(i + _columns + 1), a, b, Tint); + } + } + _mesh.Upload(); + } + + public override void Dispose() + { + _mesh.Dispose(); + base.Dispose(); + } + } +} diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/OriginalGeometryNode.cs.meta b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalGeometryNode.cs.meta new file mode 100644 index 000000000..c1f675577 --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalGeometryNode.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b4096b3ce200a447ba269be4a95c0f5a \ No newline at end of file diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/OriginalModelNode.cs b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalModelNode.cs new file mode 100644 index 000000000..cfa8f1d4f --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalModelNode.cs @@ -0,0 +1,175 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Pal3.Game.Effect.Original +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Core.DataReader.Pol; + using Engine.Core.Abstraction; + using Engine.Extensions; + using UnityEngine.Rendering; + using Core.DataReader.Data; + using Engine.Core.Implementation; + using Rendering.Material; + using Rendering.Renderer; + using UnityEngine; + + internal sealed class OriginalModelNode : OriginalEffectNode + { + private const float ReferenceFramesPerSecond = 60f; + private readonly CvdModelRenderer _model; + private readonly GameMaterialManager _materials; + private readonly Renderer[] _renderers; + private float _animationStart; + private float _opacity = 1; + private readonly List _polMaterials = new(); + private readonly List _meshes = new(); + private readonly List _opacityMaterials = new(); + private readonly MaterialPropertyBlock _properties = new(); + + public OriginalModelNode(GameObject root, EffectDefinition d, EffectTask[] tasks, Vector3 origin, float birth, OriginalEffectResources resources) + : base(root, d, tasks, origin, birth) + { + // CEffModel ignores the flexeff tint/additive/depth flags: the model + // owns those settings. SetBlend(ALPHA) overrides them only for fades. + _materials = new GameMaterialManager(new UnityMaterialFactory(), false); + try + { + if (d.TextureName.EndsWith(".cvd", StringComparison.OrdinalIgnoreCase)) + { + _model = root.AddComponent(); + _model.Init(resources.Model(d.TextureName), resources.ModelTextureProvider(d.TextureName), _materials); + } + else if (d.TextureName.EndsWith(".pol", StringComparison.OrdinalIgnoreCase)) + RenderPol(root, d.TextureName, resources); + else + throw new NotSupportedException($"Unsupported original model {d.TextureName}"); + } + catch + { + _model?.Dispose(); + foreach (Mesh mesh in _meshes) + OriginalEffectResources.Destroy(mesh); + foreach (IMaterial[] materials in _polMaterials) + _materials.ReturnToPool(materials); + _materials.Dispose(); + throw; + } + _renderers = root.GetComponentsInChildren(); + foreach (Transform child in root.GetComponentsInChildren()) + child.gameObject.layer = root.layer; + } + + private void RenderPol(GameObject root, string path, OriginalEffectResources resources) + { + ITextureResourceProvider provider = resources.ModelTextureProvider(path); + foreach (PolMesh source in resources.PolModel(path).Meshes) + { + Vector3[] positions = source.VertexInfo.GameBoxPositions.ToUnityPositions(); + foreach (PolTexture texture in source.Textures) + { + if (texture.GameBoxTriangles.Length == 0) + continue; + string[] names = texture.Material.TextureFileNames; + int main = names.Length > 1 && !string.IsNullOrEmpty(names[1]) ? 1 : 0; + ITexture2D Texture(int index) => string.IsNullOrEmpty(names[index]) + ? new UnityTexture2D(Texture2D.whiteTexture) : provider.GetTexture(names[index]); + IMaterial[] materials = _materials.CreateStandardMaterials(Rendering.RendererType.Pol, + (names[main], Texture(main)), main == 1 ? (names[0], Texture(0)) : default, + Core.Primitives.Color.White, texture.BlendFlag); + _polMaterials.Add(materials); + var mesh = new Mesh { name = "Original effect POL", indexFormat = IndexFormat.UInt32, hideFlags = HideFlags.DontSave }; + _meshes.Add(mesh); + mesh.vertices = positions; + mesh.uv = source.VertexInfo.Uvs[main].ToUnityVector2s(); + mesh.uv2 = source.VertexInfo.Uvs[0].ToUnityVector2s(); + mesh.triangles = texture.GameBoxTriangles.Reverse().ToArray(); + mesh.RecalculateBounds(); + var child = new GameObject("Original POL section") { hideFlags = HideFlags.DontSave, layer = root.layer }; + child.transform.SetParent(root.transform, false); + child.AddComponent().sharedMesh = mesh; + MeshRenderer renderer = child.AddComponent(); + renderer.sharedMaterials = materials.Select(m => (Material)m.NativeObject).ToArray(); + renderer.shadowCastingMode = ShadowCastingMode.Off; + renderer.receiveShadows = false; + } + } + } + + private void EnableOpacityOverride() + { + if (_opacityMaterials.Count > 0) + return; + foreach (Renderer renderer in _renderers) + { + if (renderer.sharedMaterial == null) + continue; + var material = new Material(renderer.sharedMaterial) + { + shader = Shader.Find("Pal3/Transparent"), + hideFlags = HideFlags.DontSave + }; + material.SetInt("_BlendSrcFactor", (int)BlendMode.SrcAlpha); + material.SetInt("_BlendDstFactor", (int)BlendMode.OneMinusSrcAlpha); + material.SetFloat("_Threshold", 2); + material.renderQueue = (int)RenderQueue.Transparent; + _opacityMaterials.Add(material); + // Existing CVD/POL cutout + blend passes become a single alpha pass. + renderer.sharedMaterials = new[] { material }; + } + } + + protected override void Apply(EffectTask t, OriginalTaskClock clock, float dt) + { + if (clock.StartedNow) + _animationStart = LocalTime; + base.Apply(t, clock, dt); + if (t.SpeedAlpha != 0) + { + EnableOpacityOverride(); + _opacity = Mathf.Clamp01(_opacity + t.SpeedAlpha * dt); + } + } + + protected override void ApplyRotationAndStretch(EffectTask t, float dt) + { + // CEffModel.cpp:238-245 adds these values once per update. Normalize + // to our chosen 60 FPS compatibility reference, independently of + // the display frame rate and the editor/seek integration step. + base.ApplyRotationAndStretch(t, dt * ReferenceFramesPerSecond); + } + + public override void Render(Camera camera) + { + Root.transform.localPosition = UnityPosition(Position + Orbit); + Root.transform.localRotation = UnityRotation(Rotation); + Root.transform.localScale = Scale; + _properties.SetColor("_TintColor", new Color(1, 1, 1, _opacity)); + foreach (Renderer renderer in _renderers) + { + renderer.enabled = Alive && Visible; + if (_opacityMaterials.Count > 0) + renderer.SetPropertyBlock(_properties); + } + if (Alive && Visible) + _model?.SetCurrentTime(Mathf.Max(0, LocalTime - _animationStart)); + } + + public override void Dispose() + { + _model?.Dispose(); + foreach (Mesh mesh in _meshes) + OriginalEffectResources.Destroy(mesh); + foreach (Material material in _opacityMaterials) + OriginalEffectResources.Destroy(material); + foreach (IMaterial[] materials in _polMaterials) + _materials.ReturnToPool(materials); + _materials.Dispose(); + base.Dispose(); + } + } +} diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/OriginalModelNode.cs.meta b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalModelNode.cs.meta new file mode 100644 index 000000000..657d3eb81 --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalModelNode.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 931441919a39d495fa8277a0dca473d0 \ No newline at end of file diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/OriginalParticleNode.cs b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalParticleNode.cs new file mode 100644 index 000000000..a6fd2259d --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalParticleNode.cs @@ -0,0 +1,279 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Pal3.Game.Effect.Original +{ + using System; + using Core.DataReader.Data; + using Core.DataReader.Par; + using Core.Primitives; + using UnityEngine; + using UnityEngine.Rendering; + + // sgpar's sampled curves and drag do not match Shuriken modules exactly. + // Simulate those semantics in C#, and use the native particle renderer. + // This is PAL3's CEffgbParticle -> ParticleEffect/particle.cpp path, not + // gbengine9/physics/gbparteffect.cpp: the combat implementation scales both + // scope and displacement and forces ONE/ONE blending regardless of BlendMode. + internal sealed class OriginalParticleNode : OriginalEffectNode + { + private struct Unit + { + public Vector3 Position, Velocity; + public float Age, Life, Size, Rotation, Direction; + public int Row; + public bool Alive; + } + + private sealed class Controller + { + public ParticleDescription Particle; + public ParticleEmitter Emitter; + public ParticleShapeFile Shape; + public ParticleSystem System; + public ParticleSystem.Particle[] Output; + public Unit[] Units; + public float CycleTime, Accumulator; + public int Count; + public bool Emitting = true; + } + + private readonly ParticleFile _file; + private readonly Controller[] _controllers; + private readonly System.Random _random; + private float _spaceScale; + private bool _loopEmission; + + public override int QuadCount + { + get + { + int n = 0; + foreach (Controller c in _controllers) + n += c.Count; + return n; + } + } + + public OriginalParticleNode(GameObject root, EffectDefinition d, EffectTask[] tasks, Vector3 origin, float birth, + OriginalEffectResources resources, uint seed, OriginalEffectOverrides edits) : base(root, d, tasks, origin, birth) + { + _random = new System.Random(unchecked((int)seed)); + _file = resources.Particle(d.ParticleFileName, edits); + _spaceScale = d.InitSize; + _controllers = new Controller[_file.Controllers.Length]; + for (int i = 0; i < _controllers.Length; i++) + { + ParticleController source = _file.Controllers[i]; + var c = new Controller { Particle = _file.Particles[source.ParticleIndex], Emitter = _file.Emitters[source.EmitterIndex] }; + int capacity = Mathf.Clamp(_file.Capacity, 1, 65536); + c.Units = new Unit[capacity]; + c.Output = new ParticleSystem.Particle[capacity]; + var child = new GameObject(c.Particle.Name) { hideFlags = HideFlags.DontSave, layer = root.layer }; + child.transform.SetParent(root.transform, false); + c.System = child.AddComponent(); + c.System.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); + var main = c.System.main; + main.playOnAwake = false; + main.maxParticles = capacity; + main.simulationSpace = ParticleSystemSimulationSpace.Local; + main.gravityModifier = 0; + main.startSpeed = 0; + main.startLifetime = 10000; + main.startSize = 1; + main.cullingMode = ParticleSystemCullingMode.AlwaysSimulate; + c.System.useAutoRandomSeed = false; + c.System.randomSeed = seed + (uint)i; + var emission = c.System.emission; + emission.enabled = false; + var shape = c.System.shape; + shape.enabled = false; + var renderer = c.System.GetComponent(); + renderer.sharedMaterial = resources.Material(OriginalEffectResources.RelativeTo(d.ParticleFileName, c.Particle.Texture), true, false, 1); + renderer.renderMode = ParticleSystemRenderMode.Billboard; + renderer.alignment = ParticleSystemRenderSpace.View; + renderer.minParticleSize = 0; + renderer.maxParticleSize = 100; + renderer.shadowCastingMode = ShadowCastingMode.Off; + renderer.receiveShadows = false; + renderer.sortMode = ParticleSystemSortMode.None; + var atlas = c.System.textureSheetAnimation; + atlas.enabled = true; + atlas.numTilesX = c.Particle.Columns; + atlas.numTilesY = c.Particle.Rows; + atlas.animation = ParticleSystemAnimationType.WholeSheet; + atlas.cycleCount = 1; + atlas.frameOverTime = new ParticleSystem.MinMaxCurve(1, AnimationCurve.Linear(0, 0, 1, 1)); + // A stopped system can discard externally supplied particles in + // editor preview. Keep it initialized and paused; C# owns time. + c.System.Play(false); + c.System.Pause(false); + if (c.Emitter.Shape == 2) + c.Shape = resources.Shape(OriginalEffectResources.RelativeTo(d.ParticleFileName, c.Emitter.ShapePath)); + _controllers[i] = c; + } + } + + protected override void Apply(EffectTask t, OriginalTaskClock clock, float dt) + { + if (clock.StartedNow) + _loopEmission = true; + Position += new Vector3(t.SpeedX, t.SpeedY, t.SpeedZ) * dt; + _spaceScale = Mathf.Max(0.001f, _spaceScale + t.XStretchSpeed * dt); + Vector3 axis = new(t.CircleAxisX, t.CircleAxisY, t.CircleAxisZ); + Orbit = OrbitPosition(axis, (LocalTime - clock.StartedAt) * t.CircleSpeed, t.CircleRadius); + } + + public override void Step(float time, float dt) + { + foreach (Controller c in _controllers) + { + ParticleEmitter e = c.Emitter; + ParticleDescription p = c.Particle; + float cycle = Mathf.Max(0.000001f, e.CycleMin - e.CycleMax); + c.CycleTime += dt; + // sgpar starts with one active emitter cycle. CEffgbParticle + // updates it before the tasks, which control future looping; + // disabling looping lets the current cycle finish normally. + if (c.CycleTime > cycle) + { + c.Emitting = _loopEmission; + if (_loopEmission) + c.CycleTime = 0; + } + float phase = c.CycleTime / cycle; + int lo = (int)(e.Emission.Evaluate(phase, 0) * e.SpeedMultiplier); + int hi = (int)(e.Emission.Evaluate(phase, 1) * e.SpeedMultiplier); + if (c.Emitting) + { + c.Accumulator += RandomRange(lo, hi) * dt; + int number = Mathf.Clamp((int)c.Accumulator, 0, c.Units.Length); + c.Accumulator -= number; + for (int n = 0; n < number; n++) + { + int slot = Array.FindIndex(c.Units, u => !u.Alive); + if (slot < 0) + { + if (_file.Overflow == 0) + break; + slot = _file.Overflow == 2 ? _random.Next(c.Units.Length) : Oldest(c.Units); + } + c.Units[slot] = Spawn(c, phase); + } + } + c.Count = 0; + for (int i = 0; i < c.Units.Length; i++) + { + ref Unit u = ref c.Units[i]; + if (!u.Alive) + continue; + u.Age += dt; + if (u.Age > u.Life) + { + u.Alive = false; + continue; + } + float t = u.Age / u.Life; + if (u.Velocity.magnitude > p.NoResistanceSpeed) + { + Vector3 drag = u.Velocity * p.Resistance; + if (p.SizeResistancePower != 0) + drag += u.Velocity.normalized * (u.Size * p.Resistance); + u.Velocity -= drag * dt; + } + u.Size = p.Size.Evaluate(t); + u.Rotation = (u.Rotation + (p.Rotation.Evaluate(t) + RandomRange(p.RotationStart, p.RotationEnd)) * dt) * u.Direction; + Vector3 offset = u.Velocity * dt; + offset.y -= p.Gravity * dt * dt / 2; + u.Velocity.y -= p.Gravity * dt; + u.Position += offset * _spaceScale; + c.Count++; + } + } + base.Step(time, dt); + if (!Alive) + _loopEmission = false; + foreach (Controller c in _controllers) + Alive |= c.Count > 0; + } + + private Unit Spawn(Controller c, float t) + { + ParticleEmitter e = c.Emitter; + ParticleDescription p = c.Particle; + Vector3 direction = new(e.Direction.Evaluate(t, 0), e.Direction.Evaluate(t, 1), e.Direction.Evaluate(t, 2)); + Vector3 scope = Raw(e.Scope) * _spaceScale; + Vector3 velocity = direction * RandomRange(e.RandomVelocity.Evaluate(t, 0), e.RandomVelocity.Evaluate(t, 1)) + + new Vector3(RandomRange(-scope.x, scope.x), RandomRange(-scope.y, scope.y), RandomRange(-scope.z, scope.z)); + Vector3 position; + if (e.Shape == 2) + position = Raw(c.Shape.Points[_random.Next(c.Shape.Points.Length)]); + else if (e.Shape == 1) + position = new Vector3(RandomRange(-100, 100), RandomRange(-100, 100), RandomRange(-100, 100)).normalized + * RandomRange(0, _random.Next(2) == 0 ? e.Hotspot : e.Hotspot + e.Falloff); + else + position = new Vector3(RandomRange(e.BoxMin.X, e.BoxMax.X), RandomRange(e.BoxMin.Y, e.BoxMax.Y), RandomRange(e.BoxMin.Z, e.BoxMax.Z)); + position = (position + Raw(e.Position)) * _spaceScale; + return new Unit + { + Alive = true, + Position = position, + Velocity = velocity, + Direction = p.RandomRotationDirection && _random.Next(2) == 0 ? -1 : 1, + Life = Mathf.Max(0.000001f, RandomRange(e.Life.Evaluate(t, 0), e.Life.Evaluate(t, 1))), + Size = p.Size.Evaluate(0), + Row = _random.Next(p.Rows) + }; + } + + public override void Render(Camera camera) + { + foreach (Controller c in _controllers) + { + int n = 0; + ParticleDescription p = c.Particle; + for (int i = 0; i < c.Units.Length; i++) + { + Unit u = c.Units[i]; + if (!u.Alive) + continue; + float phase = u.Age / u.Life; + int frame = Mathf.Min(p.Columns - 1, (int)(phase * p.Columns)); + // Native texture-sheet lookup uses normalized remaining life. + float atlasLife = (u.Row * p.Columns + frame + 0.25f) / (p.Rows * p.Columns); + var color = new UnityEngine.Color(p.Color.Evaluate(phase, 0) / 255, p.Color.Evaluate(phase, 1) / 255, + p.Color.Evaluate(phase, 2) / 255, p.Alpha.Evaluate(phase)); + // CEffgbParticle translates the view for the entire effect, + // even for sgpar's world-space mode. Task motion therefore + // moves existing particles as well as newly emitted ones. + c.Output[n++] = new ParticleSystem.Particle + { + position = UnityPosition(u.Position + Position + Orbit), + startSize = Mathf.Abs(u.Size * _spaceScale / 20), + rotation = -u.Rotation, + startColor = color, + startLifetime = 1, + remainingLifetime = 1 - atlasLife, + randomSeed = (uint)(i + 1) + }; + } + c.System.SetParticles(c.Output, n); + } + } + + private float RandomRange(float a, float b) => a + (b - a) * ((float)_random.Next(1, 10001) / 10000); + + private static Vector3 Raw(GameBoxVector3 v) => new(v.X, v.Y, v.Z); + + private static int Oldest(Unit[] units) + { + int result = 0; + for (int i = 1; i < units.Length; i++) + if (units[i].Age > units[result].Age) + result = i; + return result; + } + } +} diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/OriginalParticleNode.cs.meta b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalParticleNode.cs.meta new file mode 100644 index 000000000..ef38c2432 --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalParticleNode.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 13f42c5931db146b8a60320470832551 \ No newline at end of file diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/OriginalPortalEffect.cs b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalPortalEffect.cs new file mode 100644 index 000000000..249d0c93c --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalPortalEffect.cs @@ -0,0 +1,94 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Pal3.Game.Effect.Original +{ + using UnityEngine; + + // MXTransport is a scene effect, not flexeff group zero. + public sealed class OriginalPortalEffect : MonoBehaviour + { + private EffectMeshBuffer _base, _outer, _inner; + private float _time; + private Color32 _outerColor = new(245, 188, 28, 255), _innerColor = new(10, 67, 227, 255); + + public bool AutoAdvance { get; set; } = true; + + public void Initialize(OriginalEffectResources resources, uint type = 0) + { + GameObject Child(string name) + { + var go = new GameObject(name) { hideFlags = HideFlags.DontSave, layer = gameObject.layer }; + go.transform.SetParent(transform, false); + return go; + } + string texture = "effect\\trans.tga"; + if (resources.GameVariant == "PAL3A") + { + // MXTransport::Create 0x4aec50: the scene parameter selects + // one of six discs and independently authored cone colors. + (string suffix, Color32 outer, Color32 inner) style = type switch + { + 1 => ("s", new Color32(0, 238, 255, 255), new Color32(210, 255, 249, 255)), + 2 => ("m", new Color32(246, 0, 208, 255), new Color32(254, 224, 255, 255)), + 3 => ("l", new Color32(108, 0, 255, 255), new Color32(214, 184, 255, 255)), + 4 => ("f", new Color32(0, 255, 112, 255), new Color32(199, 255, 200, 255)), + 5 => ("t", new Color32(255, 218, 0, 255), new Color32(255, 250, 163, 255)), + 6 => ("p", new Color32(255, 0, 0, 255), new Color32(255, 152, 152, 255)), + _ => (null, _outerColor, _innerColor) + }; + if (style.suffix != null) + texture = $"effect\\trans_{style.suffix}.tga"; + _outerColor = style.outer; + _innerColor = style.inner; + } + _base = new EffectMeshBuffer(Child("Transport plane"), resources.Material(texture, true, false, 2)); + _outer = new EffectMeshBuffer(Child("Transport outer cone"), resources.Material("effect\\ray.tga", true, false, 2)); + _inner = new EffectMeshBuffer(Child("Transport inner cone"), resources.Material("effect\\ray.tga", true, false, 2)); + Sample(0); + } + + public void Sample(float time) + { + _time = time; + if (_base == null) + return; + _base.Clear(); + Quaternion rotation = Quaternion.AngleAxis(-time * 32 * 0.2f, Vector3.up); + Vector3 P(float x, float z) => OriginalEffectNode.UnityPosition(rotation * new Vector3(x, 0, z)); + _base.Quad(P(-36, -36), P(-36, 36), P(36, 36), P(36, -36), Vector2.zero, Vector2.one, Color.white); + _base.Upload(); + Cone(_outer, 41, 28, 50, rotation, _outerColor); + Cone(_inner, 34, 18, 68, Quaternion.AngleAxis(-time * 32 * 2, Vector3.up), _innerColor); + } + + private static void Cone(EffectMeshBuffer mesh, float top, float bottom, float height, Quaternion rotation, Color32 color) + { + mesh.Clear(); + Vector3 P(int i, float radius, float y) + { + float a = i * 2 * Mathf.PI / 6; + return OriginalEffectNode.UnityPosition(rotation * new Vector3(Mathf.Cos(a) * radius, y, Mathf.Sin(a) * radius)); + } + for (int i = 0; i < 6; i++) + mesh.Quad(P(i, bottom, 0), P(i + 1, bottom, 0), P(i + 1, top, height), P(i, top, height), + new Vector2(i / 3f, 0), new Vector2((i + 1) / 3f, 0.94f), color); + mesh.Upload(); + } + + private void Update() + { + if (AutoAdvance) + Sample(_time + Time.deltaTime); + } + + private void OnDestroy() + { + _base?.Dispose(); + _outer?.Dispose(); + _inner?.Dispose(); + } + } +} diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/OriginalPortalEffect.cs.meta b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalPortalEffect.cs.meta new file mode 100644 index 000000000..736451fe6 --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalPortalEffect.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 279668a7de3554dc9b94b925ca64ee9d \ No newline at end of file diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/OriginalQuadNode.cs b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalQuadNode.cs new file mode 100644 index 000000000..22fe4c7f6 --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalQuadNode.cs @@ -0,0 +1,147 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Pal3.Game.Effect.Original +{ + using System.Collections.Generic; + using Core.DataReader.Data; + using UnityEngine; + + internal sealed class OriginalQuadNode : OriginalEffectNode + { + private sealed class Quad + { + public Vector3 Position, Rotation, Orbit; + public Vector2 Size; + public Color Color; + public float Age; + + public Quad Copy() => new() { Position = Position, Rotation = Rotation, Orbit = Orbit, Size = Size, Color = Color }; + } + + private readonly List _quads = new(); + private readonly EffectMeshBuffer _mesh; + private readonly float[] _spawnAt, _lastSpawn; + private readonly int _loops; + + public override int QuadCount => _mesh.QuadCount; + + public OriginalQuadNode(GameObject root, EffectDefinition d, EffectTask[] tasks, Vector3 origin, float birth, OriginalEffectResources resources) + : base(root, d, tasks, origin, birth) + { + _mesh = new EffectMeshBuffer(root, resources.Material(d.TextureName, d.IsAdditiveBlend, d.IsZTestIgnored)); + _spawnAt = new float[Tasks.Length]; + _lastSpawn = new float[Tasks.Length]; + for (int i = 0; i < Tasks.Length; i++) + { + _spawnAt[i] = -1; + _loops = Tasks[i].RequireLoops; + } + } + + public override void Step(float time, float dt) + { + foreach (Quad q in _quads) + q.Age += dt; + if (Definition.FramePerSecond > 0) + _quads.RemoveAll(q => q.Age * Definition.FramePerSecond >= Mathf.Max(1, Definition.FrameCount) * Mathf.Max(1, _loops)); + base.Step(time, dt); + bool emitting = false; + for (int i = 0; i < Tasks.Length; i++) + { + if (Clocks[i].StartedAt < 0) + { + emitting |= !Clocks[i].Finished; + continue; + } + EffectTask t = Tasks[i]; + if (_spawnAt[i] < 0 && LocalTime > Clocks[i].StartedAt + 0.000001f) + { + Spawn(); + _spawnAt[i] = _lastSpawn[i] = LocalTime; + } + if (_spawnAt[i] < 0) + { + emitting = true; + continue; + } + if (t.CreateNew && LocalTime - _spawnAt[i] <= t.Duration + 0.000001f) + { + if (LocalTime - _lastSpawn[i] >= t.CreateTimeSlot) + { + Spawn(); + _lastSpawn[i] = LocalTime; + } + emitting = true; + } + } + Alive |= emitting; + if (!Alive) + _quads.Clear(); + } + + private void Spawn() + { + if (_quads.Count >= Mathf.Clamp(Definition.MaxQuadCount, 1, 1024)) + return; + _quads.Add(_quads.Count > 0 ? _quads[^1].Copy() : new Quad + { + Position = Position, + Rotation = Rotation, + Size = Vector2.one * Definition.InitSize, + Color = Tint + }); + } + + protected override void Apply(EffectTask t, OriginalTaskClock clock, float dt) + { + int start = t.ModifyLast ? Mathf.Max(0, _quads.Count - 1) : 0; + Vector3 axis = new(t.CircleAxisX, t.CircleAxisY, t.CircleAxisZ); + Vector3 orbit = OrbitPosition(axis, (LocalTime - clock.StartedAt) * t.CircleSpeed, t.CircleRadius); + for (int i = start; i < _quads.Count; i++) + { + Quad q = _quads[i]; + q.Size += new Vector2(t.XStretchSpeed, t.YStretchSpeed) * dt; + q.Position += new Vector3(t.SpeedX, t.SpeedY, t.SpeedZ) * dt; + q.Rotation += new Vector3(t.RotAxisX, t.SpeedRot + t.RotAxisY, t.RotAxisZ) * dt; + q.Color = ChangedColor(q.Color, t, dt); + q.Orbit = orbit; + } + } + + public override void Render(Camera camera) + { + _mesh.Clear(); + foreach (Quad q in _quads) + { + var uv = Atlas(q.Age); + Vector3 center = UnityPosition(q.Position + q.Orbit); + if (Definition.IsBillboard) + { + Vector3 right = camera != null ? Root.transform.InverseTransformVector(camera.transform.right) : Vector3.right; + Vector3 up = camera != null ? Root.transform.InverseTransformVector(camera.transform.up) : Vector3.up; + Vector3 forward = Vector3.Cross(right, up).normalized; + Quaternion spin = Quaternion.AngleAxis(-q.Rotation.y, forward); + right = spin * right * (q.Size.x / 20); + up = spin * up * (q.Size.y / 20); + _mesh.Quad(center - right - up, center + right - up, center + right + up, center - right + up, uv.min, uv.max, q.Color); + } + else + { + Quaternion r = RawRotation(q.Rotation); + Vector3 P(float x, float z) => UnityPosition(q.Position + q.Orbit + r * new Vector3(x, 0, z)); + _mesh.Quad(P(-q.Size.x, q.Size.y), P(q.Size.x, q.Size.y), P(q.Size.x, -q.Size.y), P(-q.Size.x, -q.Size.y), uv.min, uv.max, q.Color); + } + } + _mesh.Upload(); + } + + public override void Dispose() + { + _mesh.Dispose(); + base.Dispose(); + } + } +} diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/OriginalQuadNode.cs.meta b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalQuadNode.cs.meta new file mode 100644 index 000000000..0aee159a8 --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalQuadNode.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 81978aa3bb0d146e1ba874a04516b592 \ No newline at end of file diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/OriginalSparEmitter.cs b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalSparEmitter.cs new file mode 100644 index 000000000..ec7d6c777 --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalSparEmitter.cs @@ -0,0 +1,329 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Pal3.Game.Effect.Original +{ + using System; + using System.IO; + using Core.DataReader.Par; + using Core.Primitives; + using UnityEngine; + + // PAL3A 0x4c8509 / 0x4c8446 / 0x4c8bfc. Keep the native update order, + // including newly emitted units receiving a full update in their birth frame. + internal sealed class OriginalSparEmitter + { + internal struct Unit + { + public Vector3 Position, Velocity, Color; + public float Age, Life, Size, Alpha, Rotation, Spin; + public int Frame; + } + + private readonly SparGroup _group; + private readonly Vector3[] _shapePoints, _pathPoints; + private readonly float[] _controllerAngles; + private uint _random; + private float _time, _emissionFraction, _pathFraction; + private int _keyIndex, _previousKey = -1, _pathIndex; + private Vector3 _position, _boxMinimum, _boxMaximum; + private int _shapeCount, _pathCount; + + public Unit[] Units + { + get; + } + + public int Count + { + get; private set; + } + + public float Time => _time; + + public OriginalSparEmitter(SparGroup group, uint seed) + { + Validate(group); + _group = group; + _random = seed; + Units = new Unit[group.Capacity]; + int shapes = 0, paths = 0; + foreach (SparKeyframe k in group.Keyframes) + { + shapes = Math.Max(shapes, k.Shape == 3 ? k.ShapeParameters.Length / 3 : 0); + paths = Math.Max(paths, k.PathPoints.Length); + } + _shapePoints = new Vector3[shapes]; + _pathPoints = new Vector3[paths]; + _controllerAngles = new float[group.Keyframes.Length]; + } + + public void Start() + { + Count = 0; + _time = 0; + } + + public void Stop() + { + Count = 0; + _time = 0; + _keyIndex = 0; + _emissionFraction = 0; + _pathIndex = 0; + _pathFraction = 0; + } + + public static void Validate(SparGroup group) + { + if (group.Mode != 0) + throw new NotSupportedException($"SPar emitter mode {group.Mode}"); + if (group.Keyframes.Length == 0 || group.TextureFrames.Length == 0) + throw new InvalidDataException("SPar requires keyframes and texture frames"); + float previous = float.NegativeInfinity; + foreach (SparKeyframe k in group.Keyframes) + { + if (!float.IsFinite(k.Time) || k.Time < previous) + throw new InvalidDataException("SPar keyframe times must be finite and ordered"); + previous = k.Time; + if (k.Shape == 3 && k.ShapeParameters.Length == 0 || k.HasPath && k.PathPoints.Length == 0) + throw new InvalidDataException("SPar point shape/path is empty"); + for (int i = 0; i < 6; i++) + { + int maximum = i switch + { + 0 => 6, + 1 => 1, + 2 => 2, + 3 => 2, + 4 => 2, + _ => 1 + }; + if (k.Controllers[i].Kind < 0 || k.Controllers[i].Kind > maximum) + throw new NotSupportedException($"SPar controller {i}:{k.Controllers[i].Kind}"); + } + if (k.Controllers[4].Kind != 0 && k.Controllers[4].Parameters[0] <= 0) + throw new InvalidDataException("SPar frame interval must be positive"); + } + } + + private float Random() + { + // MSVCRT rand(), PAL3A 0x540f34, inclusive 0..32767. + _random = unchecked(_random * 214013 + 2531011); + return (_random >> 16 & 32767) / 32767f; + } + + private float Between(float a, float b) => a + Random() * (b - a); + + private Vector3 Between(Vector3 a, Vector3 b) => new(Between(a.x, b.x), Between(a.y, b.y), Between(a.z, b.z)); + + private static Vector3 V(GameBoxVector3 v) => new(v.X, v.Y, v.Z); + + private static float L(float a, float b, float t) => a + (b - a) * t; + + private static Vector3 V(float[] values, int index) => new(values[index], values[index + 1], values[index + 2]); + + public void Step(float dt) + { + _time += dt; + SparKeyframe[] keys = _group.Keyframes; + if (_time > keys[^1].Time) + { + _time = _group.Loop ? 0 : keys[^1].Time; + _keyIndex = _group.Loop ? 0 : keys.Length - 1; + } + while (_keyIndex + 1 < keys.Length && _time > keys[_keyIndex + 1].Time) + _keyIndex++; + SparKeyframe a = keys[_keyIndex], b = keys[Math.Min(_keyIndex + 1, keys.Length - 1)]; + float t = Mathf.Clamp01(Mathf.Max(0, _time - a.Time) / (a.Time == b.Time ? 1 : b.Time - a.Time)); + SamplePoints(a, b, t, dt); + + // The original loader overwrites the authored rates with 500/600 + // (0x4c693a). Capacity is still the file's authored particle limit. + int rate = 500 + (int)(Random() * 100); + float emission = rate * dt; + int births = (int)emission; + _emissionFraction += emission - births; + if (_emissionFraction >= 1) + { + births++; + _emissionFraction--; + } + for (int i = 0; i < births && Count < Units.Length; i++) + Units[Count++] = Spawn(a, b, t); + _previousKey = _keyIndex; + + for (int i = 0; i < Count; i++) + { + ref Unit u = ref Units[i]; + u.Age += dt; + if (u.Age >= u.Life) + { + // Native removal swaps the last unit and skips updating it + // again in this frame (0x4c8c17, 0x4c8cb3). + Units[i] = Units[--Count]; + continue; + } + UpdateUnit(ref u, a.Controllers, dt); + } + } + + private void SamplePoints(SparKeyframe a, SparKeyframe b, float t, float dt) + { + if (a.Shape == 2 && _previousKey != _keyIndex) + { + _boxMinimum = V(a.ShapeParameters, 0); + _boxMaximum = V(a.ShapeParameters, 3); + } + if (a.Shape == 3) + { + int left = a.ShapeParameters.Length / 3; + if (b.Shape == 3) + { + int right = b.ShapeParameters.Length / 3; + _shapeCount = (int)L(left, right, t); + for (int i = 0; i < _shapeCount; i++) + _shapePoints[i] = Vector3.LerpUnclamped(V(a.ShapeParameters, i * left / _shapeCount * 3), + V(b.ShapeParameters, i * right / _shapeCount * 3), t); + } + else if (_previousKey != _keyIndex) + { + _shapeCount = left; + for (int i = 0; i < left; i++) + _shapePoints[i] = V(a.ShapeParameters, i * 3); + } + } + if (a.HasPath) + { + int left = a.PathPoints.Length; + if (b.HasPath) + { + int right = b.PathPoints.Length; + _pathCount = (int)L(left, right, t); + for (int i = 0; i < _pathCount; i++) + _pathPoints[i] = Vector3.LerpUnclamped(V(a.PathPoints[i * left / _pathCount]), V(b.PathPoints[i * right / _pathCount]), t); + } + else if (_previousKey != _keyIndex) + { + _pathCount = left; + for (int i = 0; i < left; i++) + _pathPoints[i] = V(a.PathPoints[i]); + } + float step = dt * (b.HasPath ? L(a.PathRate, b.PathRate, t) : a.PathRate); + _pathIndex += (int)step; + _pathFraction += step - (int)step; + if (_pathFraction >= 1) + { + _pathIndex++; + _pathFraction--; + } + if (_pathIndex >= _pathCount) + _pathIndex = 0; + _position = _pathPoints[_pathIndex]; + } + // Native interpolation does not copy authored Position. The live + // emitter starts at zero until its path moves. + SparController controller = a.Controllers[5]; + if (a.Shape == 3 && controller.Kind == 1) + { + float[] p = controller.Parameters; + _controllerAngles[_keyIndex] += dt * p[3]; + Vector3 axis = V(p, 0); + float angle = _controllerAngles[_keyIndex]; + float sin = Mathf.Sin(angle), cos = Mathf.Cos(angle); + for (int i = 0; i < _shapeCount; i++) + { + Vector3 v = _shapePoints[i]; + // Rodrigues' matrix uses the authored axis without normalizing. + _shapePoints[i] = cos * v + sin * Vector3.Cross(axis, v) + (1 - cos) * Vector3.Dot(axis, v) * axis; + } + } + } + + private Unit Spawn(SparKeyframe a, SparKeyframe b, float t) + { + var u = new Unit + { + Alpha = Between(L(a.AlphaMinimum, b.AlphaMinimum, t), L(a.AlphaMaximum, b.AlphaMaximum, t)), + Life = Between(L(a.LifeMinimum, b.LifeMinimum, t), L(a.LifeMaximum, b.LifeMaximum, t)), + Size = Between(L(a.SizeMinimum, b.SizeMinimum, t), L(a.SizeMaximum, b.SizeMaximum, t)), + Color = Between(Vector3.LerpUnclamped(V(a.ColorMinimum), V(b.ColorMinimum), t), Vector3.LerpUnclamped(V(a.ColorMaximum), V(b.ColorMaximum), t)) + }; + Random(); + Random(); + u.Spin = Between(L(a.SpinMinimum, b.SpinMinimum, t), L(a.SpinMaximum, b.SpinMaximum, t)); + u.Velocity = Between(Vector3.LerpUnclamped(V(a.VelocityMinimum), V(b.VelocityMinimum), t), Vector3.LerpUnclamped(V(a.VelocityMaximum), V(b.VelocityMaximum), t)); + Random(); + Random(); + u.Rotation = Between(L(a.RotationMinimum, b.RotationMinimum, t), L(a.RotationMaximum, b.RotationMaximum, t)); + // The loader reads frame bounds into keyframes (+cc/+d0), but + // 0x4c8509 never copies them into the separate live state, cleared + // by 0x4c5efd. Spawn 0x4c625e still consumes rand() for its 0..0 range. + Random(); + u.Position = _position; + switch (a.Shape) + { + case 1: + float radius = Random() * L(a.ShapeParameters[0], b.Shape == 1 ? b.ShapeParameters[0] : 0, t); + float angle = Random() * (2 * Mathf.PI); + u.Position += new Vector3(Mathf.Cos(angle) * radius, 0, Mathf.Sin(angle) * radius); + break; + case 2: + u.Position += Between(_boxMinimum, _boxMaximum); + break; + case 3: + if (_shapeCount > 0) + u.Position += _shapePoints[Math.Min((int)(Random() * _shapeCount), _shapeCount - 1)]; + break; + } + return u; + } + + private void UpdateUnit(ref Unit u, SparController[] controllers, float dt) + { + SparController motion = controllers[0]; + int kind = motion.Kind; + if (kind is 2 or 3) + { + u.Velocity += V(motion.Parameters, 0) * dt; + if (kind == 3) + { + float speed = u.Velocity.magnitude; + u.Velocity = speed > 0 ? u.Velocity / speed * Mathf.Max(0, speed - u.Size * motion.Parameters[3] * speed * dt) : Vector3.zero; + } + } + if (kind is >= 1 and <= 3) + u.Position += u.Velocity * dt; + else if (kind >= 4) + { + float speed = u.Velocity.magnitude; + if (kind == 5) + speed *= 1 - u.Age / u.Life; + else if (kind == 6) + speed *= u.Age / u.Life; + u.Position += (_position - u.Position).normalized * speed * dt; + } + if (controllers[1].Kind == 1) + u.Rotation += u.Spin * dt; + if (controllers[2].Kind == 1) + { + u.Color += new Vector3(Random() * 2 - 1, Random() * 2 - 1, Random() * 2 - 1) * dt; + u.Color = new Vector3(Mathf.Clamp01(u.Color.x), Mathf.Clamp01(u.Color.y), Mathf.Clamp01(u.Color.z)); + } + else if (controllers[2].Kind == 2) + u.Color = Vector3.LerpUnclamped(V(controllers[2].Parameters, 0), V(controllers[2].Parameters, 3), u.Age / u.Life); + if (controllers[3].Kind == 1) + u.Alpha = 1 - u.Age / u.Life; + else if (controllers[3].Kind == 2) + u.Alpha = u.Age / u.Life; + if (controllers[4].Kind != 0) + { + int frame = (int)(u.Age / controllers[4].Parameters[0]); + u.Frame = controllers[4].Kind == 1 ? Math.Min(frame, _group.TextureFrames.Length - 1) : frame % _group.TextureFrames.Length; + } + } + } +} diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/OriginalSparEmitter.cs.meta b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalSparEmitter.cs.meta new file mode 100644 index 000000000..b3eeca48f --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalSparEmitter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3a14328622d249ef800a6335a6ef7a92 diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/OriginalSparNode.cs b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalSparNode.cs new file mode 100644 index 000000000..8f945825b --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalSparNode.cs @@ -0,0 +1,126 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Pal3.Game.Effect.Original +{ + using Core.DataReader.Data; + using Core.DataReader.Par; + using UnityEngine; + + internal sealed class OriginalSparNode : OriginalEffectNode + { + private readonly SparFile _file; + private readonly OriginalSparEmitter[] _emitters; + private readonly EffectMeshBuffer[] _meshes; + private bool _running; + private float _dt; + + public OriginalSparNode(GameObject root, EffectDefinition d, EffectTask[] tasks, Vector3 origin, float birth, + OriginalEffectResources resources, uint seed) : base(root, d, tasks, origin, birth) + { + _file = resources.Spar(d.ParticleFileName); + _emitters = new OriginalSparEmitter[_file.Groups.Length]; + _meshes = new EffectMeshBuffer[_emitters.Length]; + try + { + for (int i = 0; i < _emitters.Length; i++) + { + SparGroup group = _file.Groups[i]; + _emitters[i] = new OriginalSparEmitter(group, seed + (uint)i); + var child = new GameObject(group.Name) { hideFlags = HideFlags.DontSave, layer = root.layer }; + child.transform.SetParent(root.transform, false); + _meshes[i] = new EffectMeshBuffer(child, resources.SparMaterial(OriginalEffectResources.RelativeTo(d.ParticleFileName, group.Texture), _file.Shader), reverseWinding: true); + } + } + catch + { + foreach (EffectMeshBuffer mesh in _meshes) + mesh?.Dispose(); + throw; + } + } + + public override int QuadCount + { + get + { + int count = 0; + foreach (EffectMeshBuffer mesh in _meshes) + count += mesh.QuadCount; + return count; + } + } + + public override void Step(float time, float dt) + { + // CEffSParticle updates the system before processing effect tasks. + _dt = dt; + if (_running) + foreach (OriginalSparEmitter emitter in _emitters) + emitter.Step(dt); + base.Step(time, dt); + } + + protected override void Apply(EffectTask task, OriginalTaskClock clock, float dt) + { + if (clock.StartedNow) + { + foreach (OriginalSparEmitter emitter in _emitters) + emitter.Start(); + _running = true; + } + // SPar's adapter uses the complete frame delta, ignores stretch, + // task rotation/color and InitSize, and applies translation at draw. + Position += new Vector3(task.SpeedX, task.SpeedY, task.SpeedZ) * _dt; + Orbit = OrbitPosition(new Vector3(task.CircleAxisX, task.CircleAxisY, task.CircleAxisZ), + (LocalTime - clock.StartedAt) * task.CircleSpeed, task.CircleRadius); + if (clock.Finished) + { + _running = false; // native Stop clears the pool immediately + foreach (OriginalSparEmitter emitter in _emitters) + emitter.Stop(); + } + } + + public override void Render(Camera camera) + { + Vector3 right = camera == null ? Vector3.right : Root.transform.InverseTransformVector(camera.transform.right); + Vector3 up = camera == null ? Vector3.up : Root.transform.InverseTransformVector(camera.transform.up); + Quaternion rotation = RawRotation(Rotation); + for (int i = 0; i < _emitters.Length; i++) + { + EffectMeshBuffer mesh = _meshes[i]; + mesh.Clear(); + if (Visible && _running) + { + OriginalSparEmitter emitter = _emitters[i]; + for (int j = 0; j < emitter.Count; j++) + { + OriginalSparEmitter.Unit u = emitter.Units[j]; + Vector3 center = UnityPosition(Position + Orbit + rotation * u.Position); + float sin = Mathf.Sin(u.Rotation), cos = Mathf.Cos(u.Rotation); + // Native SPar builds the quad directly in view space: + // positive radians, top-left (su,sv), unlike sgpar. + Vector3 x = (right * cos + up * sin) * (u.Size / 20); + Vector3 y = (-right * sin + up * cos) * (u.Size / 20); + float[] uv = _file.Groups[i].TextureFrames[u.Frame]; + var color = new Color32((byte)(Mathf.Clamp01(u.Color.x) * 255), (byte)(Mathf.Clamp01(u.Color.y) * 255), + (byte)(Mathf.Clamp01(u.Color.z) * 255), (byte)(Mathf.Clamp01(u.Alpha) * 255)); + mesh.Quad(center - x - y, center + x - y, center + x + y, center - x + y, + new Vector2(uv[0], 1 - uv[3]), new Vector2(uv[2], 1 - uv[1]), color); + } + } + mesh.Upload(); + } + } + + public override void Dispose() + { + foreach (EffectMeshBuffer mesh in _meshes) + mesh?.Dispose(); + base.Dispose(); + } + } +} diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/OriginalSparNode.cs.meta b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalSparNode.cs.meta new file mode 100644 index 000000000..0d4ea756e --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalSparNode.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d13bc5d0fabf426fae25dc141904df61 diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/OriginalTrailNode.cs b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalTrailNode.cs new file mode 100644 index 000000000..3ad84abdd --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalTrailNode.cs @@ -0,0 +1,200 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Pal3.Game.Effect.Original +{ + using System; + using System.Collections.Generic; + using Core.DataReader.Data; + using UnityEngine; + + internal sealed class OriginalTrailNode : OriginalEffectNode + { + private readonly EffectMeshBuffer _mesh, _trailMesh; + private readonly string _kind; + private readonly System.Random _random; + private readonly Vector3 _origin; + private readonly List[] _trails; + private readonly Vector3[] _positions, _velocities; + private readonly bool[] _launched; + private float _meteorTime, _lum; + private readonly float[] _shockFrame, _shockLimit; + private readonly int _shockNodes, _cascades; + + public override int QuadCount => _mesh.QuadCount + (_trailMesh?.QuadCount ?? 0); + + public OriginalTrailNode(GameObject root, EffectDefinition d, EffectTask[] tasks, Vector3 origin, float birth, + OriginalEffectResources resources, string kind, uint seed) : base(root, d, tasks, origin, birth) + { + _kind = kind; + _origin = origin; + _random = new System.Random(unchecked((int)seed)); + _mesh = new EffectMeshBuffer(root, resources.Material(d.TextureName, d.IsAdditiveBlend, d.IsZTestIgnored)); + _launched = new bool[Tasks.Length]; + if (kind == "meteor") + { + var child = new GameObject("Original meteor chain") { hideFlags = HideFlags.DontSave, layer = root.layer }; + child.transform.SetParent(root.transform, false); + // CMeteorChain always uses depth-tested additive blending. When + // the meteor has a textured head, its ribbon is untextured. + _trailMesh = new EffectMeshBuffer(child, resources.Material(d.RotationAngle > 0 ? null : d.TextureName, true, false)); + _lum = d.TextureRepeat; + int count = Mathf.Clamp(Mathf.Abs(Mathf.RoundToInt(d.RotationAngle)), 1, 512); + _trails = new List[count]; + _positions = new Vector3[count]; + _velocities = new Vector3[count]; + for (int i = 0; i < count; i++) + _trails[i] = new List(); + } + else + { + _shockNodes = Mathf.Clamp(Mathf.RoundToInt(d.RotationAngle), 1, 512); + _cascades = Mathf.Clamp(Mathf.RoundToInt(d.Width), 1, 64); + _shockFrame = new float[_shockNodes * _cascades]; + _shockLimit = new float[_shockFrame.Length]; + } + } + + protected override void Apply(EffectTask t, OriginalTaskClock clock, float dt) + { + int task = Array.IndexOf(Tasks, t); + if (_kind == "shock") + { + if (clock.StartedNow) + for (int i = 0; i < _shockFrame.Length; i++) + { + _shockFrame[i] = (float)_random.NextDouble() * Mathf.Max(1, Definition.FrameCount); + _shockLimit[i] = t.RequireLoops + _shockFrame[i] + (i % _cascades) * Definition.BottomRadius; + } + return; // Original shock tasks only trigger the animated plane stack. + } + Vector3 launchPosition = Position; + base.Apply(t, clock, dt); + _lum = Mathf.Clamp(_lum + t.SpeedAlpha * dt, 0, 255); + if (!_launched[task] && LocalTime - clock.StartedAt >= t.SpeedRed) + { + _launched[task] = true; + for (int i = 0; i < _trails.Length; i++) + { + _trails[i].Clear(); + _positions[i] = launchPosition; + float a = i * 2 * Mathf.PI / _trails.Length; + _velocities[i] = new Vector3(Definition.BottomRiseSpeed, + _trails.Length == 1 ? Definition.TopShrink : Mathf.Sin(a) * Definition.TopShrink, + _trails.Length == 1 ? Definition.BottomShrink : Mathf.Cos(a) * Definition.BottomShrink); + } + } + } + + public override void Step(float time, float dt) + { + base.Step(time, dt); + if (_kind == "shock") + { + for (int i = 0; i < _shockFrame.Length; i++) + _shockFrame[i] += Definition.FramePerSecond * dt; + return; + } + bool running = false; + for (int i = 0; i < _launched.Length; i++) + running |= _launched[i] && !Clocks[i].Finished; + if (!running) + return; + _meteorTime += dt; + Vector3 target = _origin + new Vector3(Definition.TopRadiusIncreaseSpeed, Definition.BottomRadiusIncreaseSpeed, Definition.TopRiseSpeed); + while (_meteorTime + 0.000001f >= 0.02f) + { + _meteorTime -= 0.02f; + for (int i = 0; i < _trails.Length; i++) + { + _velocities[i] += (target - _positions[i]) * 0.02f; + _positions[i] += _velocities[i] * 0.02f; + _trails[i].Insert(0, _positions[i]); + int capacity = Mathf.Clamp(Mathf.RoundToInt(Definition.DivisionCount), 2, 4096); + if (_trails[i].Count > capacity) + _trails[i].RemoveAt(_trails[i].Count - 1); + } + } + } + + public override void Render(Camera camera) + { + _mesh.Clear(); + _trailMesh?.Clear(); + if (!Alive || !Visible) + { + _mesh.Upload(); + _trailMesh?.Upload(); + return; + } + if (_kind == "shock") + { + Quaternion rot = RawRotation(Rotation); + for (int i = 0; i < _shockFrame.Length; i++) + { + if (_shockFrame[i] > Mathf.Max(1, Definition.FrameCount) * _shockLimit[i]) + continue; + int node = i / _cascades; + float h = Definition.TopRadius; + float top = h + node * (h - Definition.TextureRepeat), half = Definition.DivisionCount / 2; + Vector3 P(float x, float y) => UnityPosition(Position + rot * (new Vector3(x, y, 0) * Definition.InitSize)); + var uv = Atlas(Definition.FramePerSecond == 0 ? 0 : _shockFrame[i] / Definition.FramePerSecond); + _mesh.Quad(P(-half, top), P(half, top), P(half, top - h), P(-half, top - h), uv.min, uv.max, Tint); + } + } + else + { + Vector3 cameraPosition = camera != null ? Root.transform.InverseTransformPoint(camera.transform.position) : new Vector3(0, 0, -10); + for (int chain = 0; chain < _trails.Length; chain++) + { + List nodes = _trails[chain]; + for (int i = 0; i + 1 < nodes.Count; i++) + { + float age = i * 0.02f; + float width = Definition.Width - Definition.BottomRadius * age; + float brightness = _lum - Definition.TopRadius * age; + if (width <= 0 || brightness <= 0) + break; + Vector3 a = UnityPosition(nodes[i]), b = UnityPosition(nodes[i + 1]); + Vector3 side = Vector3.Cross(a - b, a - cameraPosition).normalized * (width / 40); + // GameBox renders a strip with a separate width, camera + // cross vector and luminosity at each chain node. Share + // those endpoints to avoid cracks between tapered quads. + Vector3 endSide = i + 2 < nodes.Count + ? Vector3.Cross(b - UnityPosition(nodes[i + 2]), b - cameraPosition).normalized + * (Mathf.Max(0, Definition.Width - Definition.BottomRadius * (age + 0.02f)) / 40) + : side; + Color color = Tint; + color.a = Mathf.Clamp01(brightness / 255); + Color endColor = Tint; + endColor.a = Mathf.Clamp01((_lum - Definition.TopRadius * (age + 0.02f)) / 255); + _trailMesh.Quad(a - side, b - endSide, b + endSide, a + side, new Vector2((float)i / nodes.Count, 0), + new Vector2((float)(i + 1) / nodes.Count, 1), color, endColor, endColor, color); + } + if (Definition.RotationAngle > 0 && nodes.Count > 0) + { + Vector3 center = UnityPosition(nodes[0]); + Vector3 right = camera != null ? Root.transform.InverseTransformVector(camera.transform.right) : Vector3.right; + Vector3 up = camera != null ? Root.transform.InverseTransformVector(camera.transform.up) : Vector3.up; + var spin = Quaternion.AngleAxis(-Rotation.z, Vector3.Cross(right, up)); + right = spin * right * Scale.x / 20; + up = spin * up * Scale.z / 20; + var uv = Atlas(LocalTime); + _mesh.Quad(center - right - up, center + right - up, center + right + up, center - right + up, uv.min, uv.max, Tint); + } + } + } + _mesh.Upload(); + _trailMesh?.Upload(); + } + + public override void Dispose() + { + _mesh.Dispose(); + _trailMesh?.Dispose(); + base.Dispose(); + } + } +} diff --git a/Assets/Scripts/Pal3.Game/Effect/Original/OriginalTrailNode.cs.meta b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalTrailNode.cs.meta new file mode 100644 index 000000000..52b0240cf --- /dev/null +++ b/Assets/Scripts/Pal3.Game/Effect/Original/OriginalTrailNode.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5b9bae354ece74ad9a9def2a773f3f6e \ No newline at end of file diff --git a/Assets/Scripts/Pal3.Game/Effect/PortalEffect.cs b/Assets/Scripts/Pal3.Game/Effect/PortalEffect.cs index b6c5a78f3..efc788bf0 100644 --- a/Assets/Scripts/Pal3.Game/Effect/PortalEffect.cs +++ b/Assets/Scripts/Pal3.Game/Effect/PortalEffect.cs @@ -8,54 +8,24 @@ namespace Pal3.Game.Effect using System; using Data; using Engine.Core.Implementation; - using Engine.Extensions; - - using Vector3 = UnityEngine.Vector3; public sealed class PortalEffect : GameEntityScript, IEffect { - #if PAL3 - private const string PORTAL_BASE_TEXTURE_NAME = "trans.dds"; - #elif PAL3A - private const string PORTAL_BASE_TEXTURE_NAME = "trans.tga"; - #endif - - private const float PORTAL_DEFAULT_SIZE = 1.3f; - private const float PORTAL_ANIMATION_ROTATION_SPEED = 5f; - private const int PORTAL_RAY_EFFECT_ID = 0; + private IDisposable _effect; - private RotatingSpriteEffect _baseEffect; - private IDisposable _rayEffect; + protected override void OnDisableGameEntity() => Dispose(); - protected override void OnDisableGameEntity() + public void Init(GameResourceProvider resourceProvider, uint parameter) { - Dispose(); - } - - public void Init(GameResourceProvider resourceProvider, uint _) - { - _baseEffect = GameEntity.AddComponent(); - _baseEffect.Init(resourceProvider, - PORTAL_BASE_TEXTURE_NAME, - new Vector3(PORTAL_DEFAULT_SIZE, PORTAL_DEFAULT_SIZE, PORTAL_DEFAULT_SIZE), - PORTAL_ANIMATION_ROTATION_SPEED); - - _rayEffect = resourceProvider.OptionalVfx.Attach(PORTAL_RAY_EFFECT_ID, GameEntity); + _effect = resourceProvider.Effects.AttachPortal(GameEntity, parameter); } private void OnDestroy() => Dispose(); public void Dispose() { - if (_baseEffect != null) - { - _baseEffect.Dispose(); - _baseEffect.Destroy(); - _baseEffect = null; - } - - _rayEffect?.Dispose(); - _rayEffect = null; + _effect?.Dispose(); + _effect = null; } } -} \ No newline at end of file +} diff --git a/Assets/Scripts/Pal3.Game/Effect/RotatingSpriteEffect.cs b/Assets/Scripts/Pal3.Game/Effect/RotatingSpriteEffect.cs deleted file mode 100644 index c74a7612b..000000000 --- a/Assets/Scripts/Pal3.Game/Effect/RotatingSpriteEffect.cs +++ /dev/null @@ -1,88 +0,0 @@ -// ------------------------------------------------------------------------ -// Copyright (c) 2021-2026, Jiaqi (0x7c13) Liu. All rights reserved. -// See LICENSE file in the project root for license information. -// ------------------------------------------------------------------------ - -namespace Pal3.Game.Effect -{ - using System; - using Data; - using Engine.Core.Abstraction; - using Engine.Core.Implementation; - using Engine.Extensions; - using UnityEngine; - - public sealed class RotatingSpriteEffect : TickableGameEntityScript, IDisposable - { - private IGameEntity _root; - private SpriteRenderer _spriteRenderer; - private IMaterial _material; - - private float _rotationSpeed; - - protected override void OnDisableGameEntity() - { - Dispose(); - } - - public void Init(GameResourceProvider resourceProvider, - string textureName, - Vector3 scale, - float rotationSpeed) - { - _rotationSpeed = rotationSpeed; - - ITexture2D texture = resourceProvider.GetEffectTexture(textureName, out var hasAlphaChannel); - - _root = GameEntityFactory.Create($"RotatingSpriteEffect_{textureName}", - GameEntity, worldPositionStays: false); - - _spriteRenderer = _root.AddComponent(); - - _spriteRenderer.sprite = texture.CreateSprite(0, 0, - texture.Width, texture.Height, - 0.5f, 0.5f).NativeObject as Sprite; - - if (!hasAlphaChannel) - { - _material = resourceProvider.GetMaterialManager() - .CreateOpaqueSpriteMaterial(texture); - _spriteRenderer.sharedMaterial = _material.NativeObject as Material; - } - - Quaternion parentRotation = GameEntity.Transform.Rotation; - _root.Transform.LocalRotation = Quaternion.Euler(parentRotation.x + 90f, 0f, 0f); - _root.Transform.LocalScale = scale; - } - - protected override void OnUpdateGameEntity(float deltaTime) - { - if (!_root.IsNullOrDisposed()) - { - float rotationDelta = _rotationSpeed * deltaTime; - _root.Transform.LocalRotation *= Quaternion.Euler(0f, 0f, -rotationDelta); - } - } - - public void Dispose() - { - if (_material != null) - { - _material.Destroy(); - _material = null; - } - - if (_spriteRenderer != null) - { - _spriteRenderer.Destroy(); - _spriteRenderer = null; - } - - if (_root != null) - { - _root.Destroy(); - _root = null; - } - } - } -} \ No newline at end of file diff --git a/Assets/Scripts/Pal3.Game/Effect/RotatingSpriteEffect.cs.meta b/Assets/Scripts/Pal3.Game/Effect/RotatingSpriteEffect.cs.meta deleted file mode 100644 index 6f2fc7b98..000000000 --- a/Assets/Scripts/Pal3.Game/Effect/RotatingSpriteEffect.cs.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: 10c2b48514804783bc35a3b045455dd8 -timeCreated: 1666333743 \ No newline at end of file diff --git a/Assets/Scripts/Pal3.Game/Settings/GameSettings.cs b/Assets/Scripts/Pal3.Game/Settings/GameSettings.cs index 18af6f9fd..286b1d944 100644 --- a/Assets/Scripts/Pal3.Game/Settings/GameSettings.cs +++ b/Assets/Scripts/Pal3.Game/Settings/GameSettings.cs @@ -60,7 +60,6 @@ public GameSettings(ITransactionalKeyValueStore settingsStore) : base(settingsSt RegisterVisualOption(nameof(ToonCharacterLighting), "角色光照", enabled => ToonCharacterLighting = enabled); RegisterVisualOption(nameof(ToonNightFireBoost), "增强夜景火光", enabled => ToonNightFireBoost = enabled); RegisterVisualOption(nameof(BloomEnabled), "泛光", enabled => BloomEnabled = enabled); - RegisterVisualOption(nameof(OptionalVfxEnabled), "可选剧情特效", enabled => OptionalVfxEnabled = enabled); RegisterVisualOption(nameof(ToonFireShadows), "火光投影", enabled => ToonFireShadows = enabled); RegisterVisualOption(nameof(ToonEnvironmentContrast), "环境明暗对比", enabled => ToonEnvironmentContrast = enabled); @@ -185,9 +184,9 @@ public void InitOrLoadSettings() int screenRefreshRate = Screen.currentResolution.refreshRate; #endif + // Only respect the target frame rate if it is higher than the screen refresh rate if (SettingsStore.TryGet(nameof(TargetFrameRate), out int targetFrameRate) && targetFrameRate >= screenRefreshRate) - // Only respect the target frame rate if it is higher than the screen refresh rate { TargetFrameRate = targetFrameRate; } @@ -260,7 +259,6 @@ public void InitOrLoadSettings() || nightFireBoost; _sessionUsesToon ??= ToonRendering; LoadBloomSetting(defaultEnabled: desktop && !_sessionUsesToon.Value); - OptionalVfxEnabled = SettingsStore.TryGet(nameof(OptionalVfxEnabled), out bool optionalVfx) && optionalVfx; ToonFireShadows = SettingsStore.TryGet(FireShadowsSettingsKey, out bool fireShadows) ? fireShadows : desktop; diff --git a/Assets/Scripts/Pal3.Game/Settings/SettingsBase.cs b/Assets/Scripts/Pal3.Game/Settings/SettingsBase.cs index 86239dbc4..b001e637a 100644 --- a/Assets/Scripts/Pal3.Game/Settings/SettingsBase.cs +++ b/Assets/Scripts/Pal3.Game/Settings/SettingsBase.cs @@ -191,13 +191,6 @@ public bool BloomEnabled internal set => SetBooleanOption(ref _bloomEnabled, value); } - private bool _optionalVfxEnabled; - public bool OptionalVfxEnabled - { - get => _optionalVfxEnabled; - internal set => SetBooleanOption(ref _optionalVfxEnabled, value); - } - private bool _toonEnvironmentContrast; public bool ToonEnvironmentContrast { diff --git a/Assets/Scripts/Pal3.ResourceViewer/GameResourceViewer.cs b/Assets/Scripts/Pal3.ResourceViewer/GameResourceViewer.cs index 6966d0590..fba268da7 100644 --- a/Assets/Scripts/Pal3.ResourceViewer/GameResourceViewer.cs +++ b/Assets/Scripts/Pal3.ResourceViewer/GameResourceViewer.cs @@ -32,6 +32,7 @@ namespace Pal3.ResourceViewer using Engine.Services; using Engine.Utilities; using Game.Constants; + using Game.Effect.Original; using Game.Data; using Game.Rendering.Toon; using Game.Rendering.Renderer; @@ -63,6 +64,7 @@ public sealed class GameResourceViewer : MonoBehaviour [SerializeField] private AudioSource audioSource; [SerializeField] private FpsCounter fpsCounter; + private OriginalEffectViewer _effectViewer; private ICpkFileSystem _fileSystem; private GameResourceProvider _resourceProvider; private GameSettings _gameSettings; @@ -74,7 +76,7 @@ public sealed class GameResourceViewer : MonoBehaviour private IList _movFiles; private IList _mp3Files; - private static readonly Random Random = new (); + private static readonly Random Random = new(); private GameObject _renderingRoot; private Light _previewKey; @@ -99,12 +101,18 @@ private void OnEnable() _renderingRoot = new GameObject("Model"); _renderingRoot.transform.SetParent(null); - - #if UNITY_2022_1_OR_NEWER + _effectViewer = GetComponent() ?? gameObject.AddComponent(); + _effectViewer.enabled = true; + _effectViewer.Initialize(_resourceProvider.OriginalEffects, _renderingRoot.transform, DestroyExistingRenderingObjects); + DebugLogConsole.AddCommand("Effect.Load", "Play an original flexeff group (-1: transport point).", _effectViewer.Load); + DebugLogConsole.AddCommand("Effect.Seek", "Reset and resimulate the original effect to a time.", _effectViewer.Seek); + DebugLogConsole.AddCommand("Effect.Pause", "Pause or resume the original effect.", _effectViewer.Pause); + +#if UNITY_2022_1_OR_NEWER int monitorRefreshRate = (int)Screen.currentResolution.refreshRateRatio.value; - #else +#else var monitorRefreshRate = Screen.currentResolution.refreshRate; - #endif +#endif if (Application.platform is RuntimePlatform.IPhonePlayer or RuntimePlatform.Android) { @@ -124,7 +132,7 @@ private void OnEnable() } IDictionary> searchResults = - _fileSystem.BatchSearch(new[] {".pol", ".cvd", ".mv3", ".mov", ".mp3"}); + _fileSystem.BatchSearch(new[] { ".pol", ".cvd", ".mv3", ".mov", ".mp3" }); _polFiles = searchResults[".pol"]; _cvdFiles = searchResults[".cvd"]; @@ -138,15 +146,15 @@ private void OnEnable() randomMovButton.onClick.AddListener(RandMov); randomMp3Button.onClick.AddListener(RandMp3); - #if UNITY_EDITOR +#if UNITY_EDITOR extractAllCpkFilesButton.onClick.AddListener(ExtractAllCpkArchives); - #else +#else extractAllCpkFilesButton.interactable = false; - #endif +#endif DebugLogConsole.AddCommand("Search", "Search files using keyword.", Search); DebugLogConsole.AddCommand("Load", "Load a file to the viewer (.pol, .cvd, .mp3 or .mv3).", Load); - #if UNITY_EDITOR +#if UNITY_EDITOR DebugLogConsole.AddCommand("DecompileAllSceScripts", "Decompile all .sce scripts into txt format.", () => DecompileAllSceScripts(dialogueOnly: false)); @@ -159,7 +167,7 @@ private void OnEnable() DebugLogConsole.AddCommand("ExportAllGdbFiles", "Export all .gdb files into the output directory.", ExportAllGdbFiles); - #endif +#endif //__Malicious__Dev_Only__(); } @@ -208,15 +216,23 @@ private void OnRenderingSettingsChanged(string propertyName) { if (propertyName == nameof(GameSettings.AntiAliasing)) _antiAliasing?.Apply(_gameSettings.AntiAliasing); - if (_previewKey != null) ToonLighting.ConfigureKeyShadows(_previewKey, _gameSettings); + if (_previewKey != null) + ToonLighting.ConfigureKeyShadows(_previewKey, _gameSettings); } private void OnDisable() { - if (_gameSettings != null) _gameSettings.OnGameSettingsChanged -= OnRenderingSettingsChanged; + if (_effectViewer != null) + _effectViewer.enabled = false; + DebugLogConsole.RemoveCommand("Effect.Load"); + DebugLogConsole.RemoveCommand("Effect.Seek"); + DebugLogConsole.RemoveCommand("Effect.Pause"); + if (_gameSettings != null) + _gameSettings.OnGameSettingsChanged -= OnRenderingSettingsChanged; _antiAliasing?.Dispose(); _toonCameraLighting?.Detach(); - if (_previewKey != null) _previewKey.enabled = false; + if (_previewKey != null) + _previewKey.enabled = false; } private void Update() @@ -227,27 +243,37 @@ private void Update() private void RandPol() { - while (!LoadPol(_polFiles[Random.Next(0, _polFiles.Count)])) { } + while (!LoadPol(_polFiles[Random.Next(0, _polFiles.Count)])) + { + } } private void RandCvd() { - while (!LoadCvd(_cvdFiles[Random.Next(0, _cvdFiles.Count)])) { } + while (!LoadCvd(_cvdFiles[Random.Next(0, _cvdFiles.Count)])) + { + } } private void RandMv3() { - while (!LoadMv3(_mv3Files[Random.Next(0, _mv3Files.Count)])) { } + while (!LoadMv3(_mv3Files[Random.Next(0, _mv3Files.Count)])) + { + } } private void RandMov() { - while (!LoadMov(_movFiles[Random.Next(0, _movFiles.Count)])) { } + while (!LoadMov(_movFiles[Random.Next(0, _movFiles.Count)])) + { + } } private void RandMp3() { - while (!LoadMp3(_mp3Files[Random.Next(0, _mp3Files.Count)])) { } + while (!LoadMp3(_mp3Files[Random.Next(0, _mp3Files.Count)])) + { + } } private void Search(string keyword) @@ -291,7 +317,7 @@ private bool LoadPol(string filePath) ITextureResourceProvider textureProvider = _resourceProvider.CreateTextureResourceProvider( CoreUtility.GetDirectoryName(filePath, CpkConstants.DirectorySeparatorChar)); - GameObject mesh = new (CoreUtility.GetFileName(filePath, CpkConstants.DirectorySeparatorChar)); + GameObject mesh = new(CoreUtility.GetFileName(filePath, CpkConstants.DirectorySeparatorChar)); PolyModelRenderer meshRenderer = mesh.AddComponent(); mesh.transform.SetParent(_renderingRoot.transform); meshRenderer.Render(polyFile, @@ -327,7 +353,7 @@ private bool LoadCvd(string filePath) ITextureResourceProvider textureProvider = _resourceProvider.CreateTextureResourceProvider( CoreUtility.GetDirectoryName(filePath, CpkConstants.DirectorySeparatorChar)); - GameObject animationNode = new (CoreUtility.GetFileName(filePath, CpkConstants.DirectorySeparatorChar)); + GameObject animationNode = new(CoreUtility.GetFileName(filePath, CpkConstants.DirectorySeparatorChar)); CvdModelRenderer meshRenderer = animationNode.AddComponent(); animationNode.transform.SetParent(_renderingRoot.transform); @@ -443,7 +469,7 @@ private bool LoadMov(string filePath) ITextureResourceProvider textureProvider = _resourceProvider.CreateTextureResourceProvider( CoreUtility.GetDirectoryName(mtlFilePath, CpkConstants.DirectorySeparatorChar)); - GameObject animationNode = new (CoreUtility.GetFileName(filePath, CpkConstants.DirectorySeparatorChar)); + GameObject animationNode = new(CoreUtility.GetFileName(filePath, CpkConstants.DirectorySeparatorChar)); animationNode.transform.SetParent(_renderingRoot.transform); SkeletalModelRenderer skeletalModelRenderer = animationNode.AddComponent(); @@ -469,7 +495,7 @@ private bool LoadMov(string filePath) } } - #if UNITY_EDITOR +#if UNITY_EDITOR private void DecompileAllSceScripts(bool dialogueOnly) { IEnumerable sceFiles = _fileSystem.Search(".sce") @@ -478,7 +504,8 @@ private void DecompileAllSceScripts(bool dialogueOnly) string title = dialogueOnly ? "选择脚本导出目录(仅对话)" : "选择脚本导出目录"; string outputFolderPath = EditorUtility.SaveFolderPanel(title, "", ""); - if (string.IsNullOrWhiteSpace(outputFolderPath)) return; + if (string.IsNullOrWhiteSpace(outputFolderPath)) + return; outputFolderPath += $"{Path.DirectorySeparatorChar}{GameConstants.AppName}" + (dialogueOnly ? "_对话脚本" : ""); @@ -487,7 +514,9 @@ private void DecompileAllSceScripts(bool dialogueOnly) Directory.CreateDirectory(outputFolderPath); } - foreach (string sceFile in sceFiles) if (!DecompileSce(sceFile, outputFolderPath, dialogueOnly)) break; + foreach (string sceFile in sceFiles) + if (!DecompileSce(sceFile, outputFolderPath, dialogueOnly)) + break; // _charSet = new HashSet(); @@ -532,7 +561,8 @@ private void DecompileAllSceScripts(bool dialogueOnly) private void ExportAllGdbFiles() { string outputFolderPath = EditorUtility.SaveFolderPanel("选择GDB文件解包后的导出目录", "", ""); - if (string.IsNullOrWhiteSpace(outputFolderPath)) return; + if (string.IsNullOrWhiteSpace(outputFolderPath)) + return; outputFolderPath += Path.DirectorySeparatorChar + GameConstants.AppName + Path.DirectorySeparatorChar; @@ -571,7 +601,8 @@ private void ExportAllGdbFiles() private void ExtractAllCpkArchives() { string outputFolderPath = EditorUtility.SaveFolderPanel("选择CPK解包后的导出目录", "", ""); - if (string.IsNullOrWhiteSpace(outputFolderPath)) return; + if (string.IsNullOrWhiteSpace(outputFolderPath)) + return; outputFolderPath += Path.DirectorySeparatorChar + GameConstants.AppName + Path.DirectorySeparatorChar; @@ -586,6 +617,7 @@ private void ExtractAllCpkArchives() } private bool _isMovieCpkMounted = false; + private IEnumerator ExtractAllCpkArchivesInternalAsync(string outputFolderPath) { // Movie CPKs are not mounted by default since we don't need them in normal gameplay. @@ -623,9 +655,10 @@ private IEnumerator ExtractAllCpkArchivesInternalAsync(string outputFolderPath) consoleTextUI.text = "全部CPK文件已解包完成!"; extractAllCpkFilesButton.interactable = true; } - #endif +#endif + + private readonly Dictionary _actorDialogueCountMap = new(); - private readonly Dictionary _actorDialogueCountMap = new (); private bool DecompileSce(string filePath, string outputFolderPath, bool dialogueOnly) { StringBuilder output = new(); @@ -648,7 +681,7 @@ private bool DecompileSce(string filePath, string outputFolderPath, bool dialogu output.Append($"----------------------------------------------------------"); output.Append($"\n{scriptBlock.Value.Id} {scriptBlock.Value.Description}\n"); - using SafeBinaryReader scriptDataReader = new (scriptBlock.Value.ScriptData); + using SafeBinaryReader scriptDataReader = new(scriptBlock.Value.ScriptData); int dialogueIndex = 1; ICommand lastCommand = null; @@ -670,7 +703,7 @@ private bool DecompileSce(string filePath, string outputFolderPath, bool dialogu { string dialogueText = command is DialogueRenderTextCommand textCommand ? textCommand.DialogueText - : ((DialogueRenderTextWithTimeLimitCommand) command).DialogueText; + : ((DialogueRenderTextWithTimeLimitCommand)command).DialogueText; bool isTimeLimited = command is DialogueRenderTextWithTimeLimitCommand; @@ -695,8 +728,10 @@ private bool DecompileSce(string filePath, string outputFolderPath, bool dialogu if (dialogueText.Contains(":")) { var actorName = dialogueText.Substring(0, dialogueText.IndexOf(":", StringComparison.Ordinal)); - if (_actorDialogueCountMap.ContainsKey(actorName)) _actorDialogueCountMap[actorName]++; - else _actorDialogueCountMap.Add(actorName, 1); + if (_actorDialogueCountMap.ContainsKey(actorName)) + _actorDialogueCountMap[actorName]++; + else + _actorDialogueCountMap.Add(actorName, 1); } dialogueIndex++; @@ -725,7 +760,9 @@ private bool DecompileSce(string filePath, string outputFolderPath, bool dialogu output.AppendLine().AppendLine(); output.Append("-----角色对话统计----\n"); foreach (var actorDialogueCount in from entry - in _actorDialogueCountMap orderby entry.Value descending select entry) + in _actorDialogueCountMap + orderby entry.Value descending + select entry) { output.Append($"{actorDialogueCount.Key} - {actorDialogueCount.Value}句\n"); } @@ -766,6 +803,8 @@ private IEnumerator LoadMp3AudioClipAsync(string fileVirtualPath, string writePa private void DestroyExistingRenderingObjects() { + if (_effectViewer != null) + _effectViewer.Stop(); foreach (Transform child in _renderingRoot.transform) { child.gameObject.Destroy(); @@ -819,4 +858,4 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist return bytes.ToArray(); } } -} \ No newline at end of file +} diff --git a/Assets/Tests/Editor/OriginalEffectFormatTests.cs b/Assets/Tests/Editor/OriginalEffectFormatTests.cs new file mode 100644 index 000000000..a31c23bb4 --- /dev/null +++ b/Assets/Tests/Editor/OriginalEffectFormatTests.cs @@ -0,0 +1,123 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Pal3.Core.Tests +{ + using System; + using System.IO; + using System.Linq; + using DataReader; + using DataReader.Cpk; + using DataReader.Cpk.Lzo; + using DataReader.Data; + using DataReader.Par; + using NUnit.Framework; + + public sealed class OriginalEffectFormatTests + { + [Test] + public void FlexeffReadsFloatSizeTaskPaddingAndFirstValidDuplicate() + { + byte[] bytes = SyntheticTable(); + var file = ((IFileReader)new EffectDefinitionFileReader()).Read(bytes, 936); + Assert.That(file.Groups.Length, Is.EqualTo(560)); + Assert.That(file.TryGetType(123, out EffectDefinition d), Is.True); + Assert.That(d.InitSize, Is.EqualTo(1.25f)); + EffectTask task = file.Groups[0].Objects.Single().Tasks.Single(); + Assert.That(task.SpeedX, Is.EqualTo(12.5f)); + Assert.That(task.YStretchSpeed, Is.EqualTo(-2f)); + Assert.That(task.Duration, Is.EqualTo(3.5f)); + } + + [Test] + public void OrdinaryLzoRejectsTrailingBytesButExplicitPrefixReportsConsumedLength() + { + byte[] stream = { 21, 10, 20, 30, 40, 17, 0, 0, 99 }; + var output = new byte[4]; + Assert.Throws(() => MiniLzo.Decompress(stream, output)); + Assert.That(MiniLzo.DecompressPrefix(stream, output), Is.EqualTo(8)); + Assert.That(output, Is.EqualTo(new byte[] { 10, 20, 30, 40 })); + } + + [Test] + public void CallbackAndSoundUnionsRetainOriginalFieldOrder() + { + var task = new EffectTask + { + XStretchSpeed = 10, + SpeedX = 1, + SpeedY = 2, + SpeedZ = 3, + CircleRadius = 20, + RotAxisX = 4, + RotAxisY = 5, + RotAxisZ = 6, + CircleAxisX = 30, + CircleAxisY = 7, + CircleAxisZ = 8, + CircleSpeed = 9, + SpeedAlpha = 40, + SpeedRed = 11, + SpeedGreen = 12, + SpeedBlue = 13 + }; + Assert.That(task.Callbacks().Select(c => c.groupId), Is.EqualTo(new[] { 10, 20, 30, 40 })); + Assert.That(task.Callbacks().Last().position.Z, Is.EqualTo(13)); + Assert.That(task.Sounds().Select(s => s.delay), Is.EqualTo(new[] { 1f, 3f, 4f, 6f })); + } + + [Test] + public void ParticleCurvePreservesReversingSamplesAndUsesOriginalBracketLookup() + { + var points = Enumerable.Range(0, 101).Select(i => new Primitives.GameBoxVector3(i / 100f, i / 100f * 2, 0)).ToArray(); + var curve = new ParticleCurve { Channels = 1, Samples = new[] { points, points, points } }; + Assert.That(curve.Evaluate(0.325f), Is.EqualTo(0.65f).Within(0.00001f)); + Assert.That(curve.Evaluate(1.1f), Is.Zero); + points[43].X = 0.41f; + Assert.That(curve.Evaluate(0.425f), Is.EqualTo(0.87f).Within(0.00001f)); + } + + [Test] + public void TruncatedCsfAndUnknownParticleVersionAreRejected() + { + Assert.Throws(() => ((IFileReader)new ParticleShapeFileReader()).Read(new byte[] { 99, 115, 102, 0 }, 936)); + byte[] par = { 115, 103, 112, 97, 114, 0, 2, 0, 1, 0 }; + Assert.Throws(() => ((IFileReader)new ParticleFileReader()).Read(par, 936)); + } + + private static byte[] SyntheticTable() + { + var bytes = new byte[EffectDefinitionFileReader.Pal3Size]; + using var stream = new MemoryStream(bytes); + using var w = new BinaryWriter(stream); + for (int i = 0; i < 2; i++) + { + stream.Position = i * 280; + w.Write(0x71756164u); + stream.Position = i * 280 + 36; + w.Write(123u); + stream.Position = i * 280 + 200; + w.Write(i == 0 ? 1.25f : 7f); + stream.Position = i * 280 + 212; + w.Write((byte)1); + } + stream.Position = 1324 * 280; + w.Write(1); + w.Write(123u); + long task = 1324 * 280 + 68; + stream.Position = task + 4; + w.Write(12.5f); + stream.Position = task + 76; + w.Write(3.5f); + stream.Position = task + 88; + w.Write((byte)1); + w.Write((byte)0); + w.Write((byte)0); + w.Write((byte)0xCC); + w.Write(-2f); + return bytes; + } + } +} diff --git a/Assets/Tests/Editor/OriginalEffectFormatTests.cs.meta b/Assets/Tests/Editor/OriginalEffectFormatTests.cs.meta new file mode 100644 index 000000000..fa0af2522 --- /dev/null +++ b/Assets/Tests/Editor/OriginalEffectFormatTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c8efb240fc6a64537ba57e4b865f14de \ No newline at end of file diff --git a/Assets/Tests/Editor/SparFormatTests.cs b/Assets/Tests/Editor/SparFormatTests.cs new file mode 100644 index 000000000..d8694652d --- /dev/null +++ b/Assets/Tests/Editor/SparFormatTests.cs @@ -0,0 +1,108 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Pal3.Core.Tests +{ + using System; + using System.IO; + using System.Text; + using DataReader; + using DataReader.Data; + using DataReader.Par; + using NUnit.Framework; + + public sealed class SparFormatTests + { + [Test] + public void SparClassNameMatchesTheOriginalRegistration() + { + Assert.That(EffectClass.Name(0x8438975e), Is.EqualTo("S-Particle")); + } + + [Test] + public void SparReadsKeyframeShapesControllersAndEditorTail() + { + byte[] bytes = Fixture(); + var file = ((IFileReader)new SparFileReader()).Read(bytes, 936); + Assert.That(file.Groups.Length, Is.EqualTo(1)); + SparGroup group = file.Groups[0]; + Assert.That(group.Name, Is.EqualTo("synthetic")); + Assert.That(group.Texture, Is.EqualTo("Media\\test.tga")); + Assert.That(group.Capacity, Is.EqualTo(32)); + Assert.That(group.Loop, Is.True); + Assert.That(group.TextureFrames[0], Is.EqualTo(new[] { 0f, 0f, 1f, 1f })); + Assert.That(group.Keyframes[0].Time, Is.EqualTo(1.25f)); + Assert.That(group.Keyframes[0].ShapeParameters, Is.EqualTo(new[] { 10f })); + Assert.That(group.Keyframes[0].VelocityMaximum.Z, Is.EqualTo(6)); + Assert.That(group.Keyframes[0].FrameMinimum, Is.EqualTo(5)); + Assert.That(group.Keyframes[0].FrameMaximum, Is.EqualTo(15)); + Assert.That(group.Keyframes[0].Controllers[0].Parameters, Is.EqualTo(new[] { 1f, 2f, 3f })); + Assert.That(file.Shader, Is.EqualTo(1)); + Assert.That(file.UnusedByteCount, Is.EqualTo(3)); + } + + [Test] + public void SparRejectsTruncationInvalidHeaderCountsAndControllerKinds() + { + IFileReader reader = new SparFileReader(); + byte[] bytes = Fixture(); + Assert.Throws(() => reader.Read(bytes[..900], 936)); + byte[] changed = (byte[])bytes.Clone(); + changed[8] = 0; + Assert.Throws(() => reader.Read(changed, 936)); + changed = (byte[])bytes.Clone(); + BitConverter.GetBytes(int.MaxValue).CopyTo(changed, 4); + Assert.Throws(() => reader.Read(changed, 936)); + changed = (byte[])bytes.Clone(); + BitConverter.GetBytes(99).CopyTo(changed, 808 + 16 + 28 + 4 + 4 + 96); + Assert.Throws(() => reader.Read(changed, 936)); + } + + private static byte[] Fixture() + { + using var stream = new MemoryStream(); + using var w = new BinaryWriter(stream); + w.Write(new byte[264 + 544]); + stream.Position = 0; + w.Write(1f); + w.Write(1); + w.Write(Encoding.ASCII.GetBytes("__PARTICLE_EFFECT(Softstar_Ltd)_EXPORT__")); + stream.Position = 268; + w.Write(32); + w.Write(192); + w.Write(0); + w.Write(1); + w.Write(1); + w.Write(1); + w.Write(Encoding.ASCII.GetBytes("synthetic")); + stream.Position = 264 + 284; + w.Write(Encoding.ASCII.GetBytes("Media\\test.tga")); + stream.Position = 808; + foreach (float value in new[] { 0f, 0f, 1f, 1f }) + w.Write(value); + w.Write(1.25f); + w.Write(new byte[12]); + w.Write(15); + w.Write(30); + w.Write(1); + w.Write(10f); + w.Write(0); + for (int i = 1; i <= 22; i++) + w.Write((float)i); + w.Write(5); + w.Write(15); + w.Write(2); + w.Write(1f); + w.Write(2f); + w.Write(3f); + w.Write(new byte[5 * 4]); + w.Write(new byte[16]); + w.Write(1); + w.Write(new byte[16]); + w.Write(new byte[3]); + return stream.ToArray(); + } + } +} diff --git a/Assets/Tests/Editor/SparFormatTests.cs.meta b/Assets/Tests/Editor/SparFormatTests.cs.meta new file mode 100644 index 000000000..f45c5c846 --- /dev/null +++ b/Assets/Tests/Editor/SparFormatTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 44b32369cdd9481dbe812d70d6d1adb9 diff --git a/Assets/Tests/Editor/TgaAlphaTests.cs b/Assets/Tests/Editor/TgaAlphaTests.cs new file mode 100644 index 000000000..4de1b2a7e --- /dev/null +++ b/Assets/Tests/Editor/TgaAlphaTests.cs @@ -0,0 +1,49 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Pal3.Core.Tests +{ + using DataReader.Tga; + using NUnit.Framework; + + public sealed class TgaAlphaTests + { + [Test] + public void RgbTgaExpandsToOpaqueAlphaIncludingTheCallerBufferPath() + { + var bytes = new byte[24]; + bytes[18] = 30; + bytes[19] = 20; + bytes[20] = 10; + bytes[21] = 60; + bytes[22] = 50; + bytes[23] = 40; + var expected = new byte[] { 10, 20, 30, 255, 40, 50, 60, 255 }; + Assert.That(TgaDecoder.Decode24BitDataToRgba32(bytes, 2, 1), Is.EqualTo(expected)); + var buffer = new byte[12]; + buffer[8] = 99; + TgaDecoder.Decode24BitDataToRgba32(bytes, 2, 1, buffer); + Assert.That(buffer[..8], Is.EqualTo(expected)); + Assert.That(buffer[8], Is.EqualTo(99)); + } + + [Test] + public void RgbaTgaPreservesItsAuthoredAlpha() + { + var bytes = new byte[26]; + bytes[18] = 30; + bytes[19] = 20; + bytes[20] = 10; + bytes[21] = 0; + bytes[22] = 60; + bytes[23] = 50; + bytes[24] = 40; + bytes[25] = 100; + var rgba = TgaDecoder.Decode32BitDataToRgba32(bytes, 2, 1, out bool alpha); + Assert.That(rgba, Is.EqualTo(new byte[] { 10, 20, 30, 0, 40, 50, 60, 100 })); + Assert.That(alpha, Is.True); + } + } +} diff --git a/Assets/Tests/Editor/TgaAlphaTests.cs.meta b/Assets/Tests/Editor/TgaAlphaTests.cs.meta new file mode 100644 index 000000000..2fa940013 --- /dev/null +++ b/Assets/Tests/Editor/TgaAlphaTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dc7c37c1190142da82ab61274facf7b9 diff --git a/Assets/Tests/Game/Editor/OriginalEffectReviewTests.cs b/Assets/Tests/Game/Editor/OriginalEffectReviewTests.cs new file mode 100644 index 000000000..597eeed5a --- /dev/null +++ b/Assets/Tests/Game/Editor/OriginalEffectReviewTests.cs @@ -0,0 +1,304 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Pal3.Game.Tests +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Reflection; + using System.Runtime.Serialization; + using Core.Command.SceCommands; + using Core.DataReader.Cpk; + using Core.DataReader.Cvd; + using Core.DataReader.Data; + using Core.FileSystem; + using Core.Primitives; + using Core.Utilities; + using Effect; + using Effect.Original; + using Engine.Core.Implementation; + using Engine.Extensions; + using NUnit.Framework; + using Scene; + using UnityEngine; + + [Category("LocalGameData")] + public sealed class OriginalEffectReviewTests + { + private static OriginalEffectResources Open(string variant) + { + return new OriginalEffectResources(OpenFileSystem(variant)); + } + + private static CpkFileSystem OpenFileSystem(string variant) + { + string path = Environment.GetEnvironmentVariable(variant + "_TEST_DATA_ROOT") ?? Path.GetFullPath("../Game Data/" + variant); + if (!Directory.Exists(path)) + Assert.Ignore("Local game data is unavailable: " + variant); + var crc = new Crc32Hash(); + crc.Init(); + var fs = new CpkFileSystem(path, crc, 936); + fs.Mount("basedata/basedata.cpk", 936); + return fs; + } + + private sealed class EffectScene : IDisposable + { + public readonly GameObject Root = new("Effect position regression"); + public readonly GameScene Scene; + public readonly EffectManager Manager; + public readonly OriginalEffectResources Resources; + public readonly EffectRuntime Runtime; + + public EffectScene() + { +#if PAL3 + var fs = OpenFileSystem("PAL3"); +#else + var fs = OpenFileSystem("PAL3A"); +#endif + Root.SetActive(false); + Scene = Root.AddComponent(); + Runtime = new EffectRuntime(fs, 936); + Resources = Runtime.Original; + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + var scenes = (SceneManager)FormatterServices.GetUninitializedObject(typeof(SceneManager)); + typeof(SceneManager).GetField("_currentGameScene", flags).SetValue(scenes, Scene); + typeof(SceneManager).GetField("_currentSceneRoot", flags).SetValue(scenes, new GameEntity(Root)); + Manager = (EffectManager)FormatterServices.GetUninitializedObject(typeof(EffectManager)); + typeof(EffectManager).GetField("_sceneManager", flags).SetValue(Manager, scenes); + typeof(EffectManager).GetField("_effects", flags).SetValue(Manager, Runtime); + } + + public OriginalEffectPlayer PlayOriginal(int group = 15) + { + // These tests exercise command dispatch and placement, including + // PAL3A's shape-change IDs, without requiring their SPar renderer. + Resources.Definitions.Groups[group] = new EffectGroup(); + Manager.Execute(new EffectPlayCommand(group)); + OriginalEffectPlayer[] players = Root.GetComponentsInChildren(true); + Assert.That(players, Has.Length.EqualTo(1), "Missing actors must not suppress native playback"); + Assert.That(players[0].Error, Is.Null); + return players[0]; + } + + public void Dispose() + { + Runtime.Dispose(); + UnityEngine.Object.DestroyImmediate(Root); + } + } + + [TestCase(false)] + [TestCase(true)] + public void MissingActorKeepsThePreviousEffectPosition(bool setPosition) + { + using var scene = new EffectScene(); + Vector3 expected = Vector3.zero; + if (setPosition) + { + scene.Manager.Execute(new EffectSetPositionCommand(80, 120, -160)); + expected = new GameBoxVector3(80, 120, -160).ToUnityPosition(); + } + scene.Manager.Execute(new EffectAttachToActorCommand(32767)); + Assert.That(scene.PlayOriginal().transform.localPosition, Is.EqualTo(expected)); + } + + [Test] + public void RemovedActorDoesNotInvalidateItsCapturedEffectPosition() + { + using var scene = new EffectScene(); + scene.Root.transform.position = new Vector3(7, 0, 4); + scene.Root.transform.rotation = Quaternion.Euler(0, 25, 0); + var actor = new GameObject("Effect snapshot actor"); + actor.transform.SetParent(scene.Root.transform, false); + actor.transform.localPosition = new Vector3(2, 3, -4); + scene.Scene.GetAllActorGameEntities()[7] = new GameEntity(actor); + scene.Manager.Execute(new EffectAttachToActorCommand(7)); + Vector3 expected = actor.transform.localPosition + Vector3.up * 0.1f; + UnityEngine.Object.DestroyImmediate(actor); + Assert.That(Vector3.Distance(scene.PlayOriginal().transform.localPosition, expected), Is.LessThan(0.00001f)); + } + + [TestCase(164)] + [TestCase(315)] + public void MissingActorSkipsShapeChangesWithoutSuppressingOriginalEffects(int group) + { + using var scene = new EffectScene(); + scene.Manager.Execute(new EffectSetPositionCommand(80, 120, -160)); + scene.Manager.Execute(new EffectAttachToActorCommand(32767)); + Vector3 expected = new GameBoxVector3(80, 120, -160).ToUnityPosition(); + Assert.That(scene.PlayOriginal(group).transform.localPosition, Is.EqualTo(expected)); + } + + [Test] + public void UnsupportedGroupsProduceOneDiagnosticWithoutCreatingPartialEffects() + { + using var scene = new EffectScene(); + UnityEngine.TestTools.LogAssert.Expect(LogType.Warning, new System.Text.RegularExpressions.Regex("Effect -2:")); + Assert.That(scene.Runtime.Play(-2, new GameEntity(scene.Root), Vector3.zero), Is.False); + Assert.That(scene.Runtime.Play(-2, new GameEntity(scene.Root), Vector3.zero), Is.False); + Assert.That(scene.Root.transform.childCount, Is.Zero); + } + + [Test] + public void SceneCacheInvalidationCancelsAnInFlightPreload() + { + using var runtime = new EffectRuntime(OpenFileSystem("PAL3"), 936); + var preload = runtime.PreloadAsync(15); + try + { + Assert.That(preload.MoveNext(), Is.True); + runtime.InvalidatePreloads(); + Assert.That(preload.MoveNext(), Is.False); + } + finally + { + (preload as IDisposable)?.Dispose(); + } + } + + private static OriginalEffectObjectOverride Object(OriginalEffectResources resources, int group, string kind) + { + for (int index = 0; index < resources.Definitions.Groups[group].Objects.Length; index++) + { + EffectGroupObject obj = resources.Definitions.Groups[group].Objects[index]; + resources.Definitions.TryGetType(obj.TypeCrc, out var definition); + if (EffectClass.Name(definition.ClassNameCrc) == kind) + return new OriginalEffectObjectOverride { GroupId = group, ObjectIndex = index, Definition = definition, Tasks = obj.Tasks }; + } + throw new InvalidOperationException($"Group {group} has no {kind}"); + } + + private static void HideOtherObjects(OriginalEffectResources resources, OriginalEffectOverrides edits, OriginalEffectObjectOverride selected) + { + edits.Objects.Add(selected); + for (int i = 0; i < resources.Definitions.Groups[selected.GroupId].Objects.Length; i++) + if (i != selected.ObjectIndex) + edits.Objects.Add(new OriginalEffectObjectOverride { GroupId = selected.GroupId, ObjectIndex = i, Hidden = true }); + } + + [TestCase(".cvd")] + [TestCase(".unsupported")] + public void ModelDiagnosisRejectsMissingAndUnknownFormats(string extension) + { + using var resources = Open("PAL3"); + var edits = ScriptableObject.CreateInstance(); + try + { + var edit = Object(resources, 173, "model"); + edit.Definition.TextureName = "effect\\missing-review-model" + extension; + edits.Objects.Add(edit); + Assert.That(resources.Diagnose(173, edits).Any(e => e.Contains(edit.Definition.TextureName)), Is.True); + Assert.That(resources.Diagnose(173), Is.Empty, "Override failures must not poison the original cache"); + } + finally + { + UnityEngine.Object.DestroyImmediate(edits); + } + } + + [Test] + public void CvdDiagnosisIncludesNestedModelTextures() + { + using var resources = Open("PAL3"); + var d = Object(resources, 173, "model").Definition; + var node = Geometries(resources.Model(d.TextureName).RootNodes).First(); + node.Mesh.MeshSections[0].Material.TextureFileNames[0] = "missing-review-nested-texture.tga"; + Assert.That(resources.Diagnose(173).Any(e => e.Contains("missing-review-nested-texture.tga")), Is.True); + } + + private static IEnumerable Geometries(IEnumerable nodes) + { + foreach (var node in nodes) + { + if (node.IsGeometryNode) + yield return node; + foreach (var child in Geometries(node.Children)) + yield return child; + } + } + + [Test] + public void PolDiagnosisAllowsAnEmptyTextureSlot() + { + using var resources = Open("PAL3A"); + var d = Object(resources, 157, "model").Definition; + resources.PolModel(d.TextureName).Meshes[0].Textures[0].Material.TextureFileNames[0] = ""; + Assert.That(resources.Diagnose(157), Is.Empty); + } + + [Test] + public void LivePolModelRetainsItsAuxiliaryTextureWhenResourcesAreTrimmed() + { + using var resources = Open("PAL3A"); + var edits = ScriptableObject.CreateInstance(); + var root = new GameObject("POL lightmap lifetime"); + try + { + HideOtherObjects(resources, edits, Object(resources, 157, "model")); + var player = root.AddComponent(); + player.AutoAdvance = false; + player.Initialize(resources, 157, overrides: edits); + player.Advance(0.5f); + player.Render(null); + Assert.That(player.Error, Is.Null); + var renderers = root.GetComponentsInChildren(); + Assert.That(renderers, Is.Not.Empty); + Texture main = renderers[0].sharedMaterial.mainTexture; + Texture lightmap = resources.Texture("effect\\q08qiao.tga"); + foreach (var renderer in renderers) + foreach (var material in renderer.sharedMaterials) + { + Assert.That(material.HasProperty("_ShadowTex"), Is.True); + material.SetTexture("_ShadowTex", lightmap); + material.SetFloat("_HasShadowTex", 1); + } + resources.TrimUnusedRenderAssets(new[] { root }); + Assert.That(main != null, Is.True, "Live model main texture was released"); + Assert.That(lightmap != null, Is.True, "Live model auxiliary texture was released"); + UnityEngine.Object.DestroyImmediate(root); + resources.TrimUnusedRenderAssets(Array.Empty()); + Assert.That(main == null && lightmap == null, Is.True, "Unreferenced model textures were retained"); + } + finally + { + if (root != null) + UnityEngine.Object.DestroyImmediate(root); + UnityEngine.Object.DestroyImmediate(edits); + } + } + + [Test] + public void ExplosiveSubdivisionsUseTheOriginalFloatToIntTruncation() + { + using var resources = Open("PAL3A"); + var edits = ScriptableObject.CreateInstance(); + var root = new GameObject("Explosive subdivisions"); + try + { + var edit = Object(resources, 173, "explosive"); + edit.Definition.RotationAngle = 1.9f; + edit.Definition.DivisionCount = 2.9f; + edit.Tasks = new[] { new EffectTask { IsValid = true, StartTime = -1, Duration = 1 } }; + HideOtherObjects(resources, edits, edit); + var player = root.AddComponent(); + player.AutoAdvance = false; + player.Initialize(resources, 173, overrides: edits); + player.AdvanceFrame(0.1f); + player.Render(null); + Assert.That(player.Error, Is.Null); + Assert.That(player.QuadCount, Is.EqualTo(2), "C++ truncates 1.9 x 2.9 to 1 x 2"); + } + finally + { + UnityEngine.Object.DestroyImmediate(root); + UnityEngine.Object.DestroyImmediate(edits); + } + } + } +} diff --git a/Assets/Tests/Game/Editor/OriginalEffectReviewTests.cs.meta b/Assets/Tests/Game/Editor/OriginalEffectReviewTests.cs.meta new file mode 100644 index 000000000..1c2b7d0bc --- /dev/null +++ b/Assets/Tests/Game/Editor/OriginalEffectReviewTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e787d220ff6c4b5db6ade66fa941689d diff --git a/Assets/Tests/Game/Editor/OriginalEffectTests.cs b/Assets/Tests/Game/Editor/OriginalEffectTests.cs new file mode 100644 index 000000000..4172d40f1 --- /dev/null +++ b/Assets/Tests/Game/Editor/OriginalEffectTests.cs @@ -0,0 +1,915 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Pal3.Game.Tests +{ + using System; + using System.IO; + using System.Linq; + using System.Collections.Generic; + using Core.DataReader.Cpk; + using Core.FileSystem; + using Effect.Original; + using Core.DataReader.Data; + using Core.Utilities; + using Engine.Core.Implementation; + using Settings; + using System.Reflection; + using UnityEditor; + using NUnit.Framework; + using UnityEngine; + + [Category("LocalGameData")] + public sealed class OriginalEffectTests + { + // Script groups plus the scene groups. Numeric IDs are integration + // contracts, not copied original binary fixtures. + public static readonly int[] SceneGroups = { 6,12,14,15,25,83,98,175,224,240,251,252,254,255,256,257, + 259,260,261,262,263,264,265,266,270,275,276,277,278,290,295,297,314,331,332,337,339,341, + 368,369,376,389,400,450,451,452,453,454,455,456,457,458,465,467,468,515 }; + + private static OriginalEffectResources Open() + { + string path = Environment.GetEnvironmentVariable("PAL3_TEST_DATA_ROOT") ?? Path.GetFullPath("../Game Data/PAL3"); + if (!Directory.Exists(path)) + Assert.Ignore("Set PAL3_TEST_DATA_ROOT to the local PAL3 data directory"); + var crc = new Crc32Hash(); + crc.Init(); + var fs = new CpkFileSystem(path, crc, 936); + fs.Mount("basedata/basedata.cpk", 936); + return new OriginalEffectResources(fs); + } + + [Test] + public void EverySceneGroupHasACompleteOriginalDependencyClosure() + { + using var resources = Open(); + var errors = SceneGroups.SelectMany(id => resources.Diagnose(id).Select(e => $"{id}: {e}")).ToArray(); + Assert.That(errors, Is.Empty, string.Join("\n", errors)); + } + + [Test] + public void EverySceneGroupCreatesSimulatesRendersAndDisposes() + { + using var resources = Open(); + var cameraRoot = new GameObject("Effect test camera"); + var camera = cameraRoot.AddComponent(); + camera.enabled = false; + var errors = new List(); + try + { + foreach (int id in SceneGroups) + { + var root = new GameObject($"Original effect test {id}"); + try + { + var player = root.AddComponent(); + player.AutoAdvance = false; + player.Initialize(resources, id, 123); + Assert.That(player.Error, Is.Null, $"{id} initialization"); + int maximum = 0; + for (int tick = 0; tick < 650; tick++) + { + player.Advance(0.02f); + if (tick % 10 == 0) + { + player.Render(camera); + maximum = Math.Max(maximum, player.QuadCount); + } + if (player.Error != null) + break; + } + Assert.That(player.Error, Is.Null, $"{id} simulation"); + Assert.That(maximum, Is.GreaterThan(0), $"{id}: no visible geometry or particles"); + } + catch (Exception ex) + { + errors.Add($"{id}: {ex}"); + } + finally + { + UnityEngine.Object.DestroyImmediate(root); + } + } + } + finally + { + UnityEngine.Object.DestroyImmediate(cameraRoot); + } + Assert.That(errors, Is.Empty, string.Join("\n", errors)); + } + + [Test] + public void SeekReproducesGeometryAndDoesNotReplayAudio() + { + using var resources = Open(); + var root = new GameObject("Effect deterministic test"); + var cameraRoot = new GameObject("Camera"); + var camera = cameraRoot.AddComponent(); + camera.enabled = false; + try + { + int audio = 0; + var player = root.AddComponent(); + player.AutoAdvance = false; + player.Initialize(resources, 254, 123, sound: _ => audio++); + for (int i = 0; i < 150; i++) + player.Advance(0.02f); + player.Render(camera); + int sounds = audio; + Assert.That(sounds, Is.GreaterThan(0), "Original audio tasks must execute during forward playback"); + Vector3[] expected = Vertices(root); + player.Seek(1); + player.Seek(3); + player.Render(camera); + Assert.That(audio, Is.EqualTo(sounds)); + Assert.That(Vertices(root), Is.EqualTo(expected)); + } + finally + { + UnityEngine.Object.DestroyImmediate(root); + UnityEngine.Object.DestroyImmediate(cameraRoot); + } + } + + [Test] + public void ParticleCurveOverridesDoNotMutateCachedOriginalData() + { + using var resources = Open(); + var edits = ScriptableObject.CreateInstance(); + try + { + const string path = "effect\\gbParticle\\yiwai3.par"; + Assert.That(OriginalEffectResources.RelativeTo("effect/gbParticle/yiwai3.par", "partexture/guangdianer.jpg"), + Is.EqualTo("effect\\gbParticle\\partexture\\guangdianer.jpg")); + float original = resources.Particle(path).Particles[0].Size.Evaluate(0.5f); + edits.ParticleCurves.Add(new OriginalParticleCurveOverride + { + File = path, + Parameter = "Size", + Index = 0, + Channels = new[] { AnimationCurve.Constant(0, 1, 17) } + }); + Assert.That(resources.Particle(path, edits).Particles[0].Size.Evaluate(0.5f), Is.EqualTo(17)); + Assert.That(resources.Particle(path).Particles[0].Size.Evaluate(0.5f), Is.EqualTo(original)); + } + finally + { + UnityEngine.Object.DestroyImmediate(edits); + } + } + + [Test] + public void NativeParticleRendererRetainsManuallySimulatedParticles() + { + using var resources = Open(); + var root = new GameObject("Native particle handoff"); + var cameraRoot = new GameObject("Camera"); + var camera = cameraRoot.AddComponent(); + camera.enabled = false; + try + { + var player = root.AddComponent(); + player.AutoAdvance = false; + player.Initialize(resources, 175, 123); + for (int i = 0; i < 100; i++) + player.Advance(0.02f); + player.Render(camera); + Assert.That(root.GetComponentsInChildren().Sum(ps => ps.particleCount), Is.GreaterThan(0)); + Vector3[] positions = ParticlePositions(root); + player.Seek(1); + player.Seek(2); + player.Render(camera); + Assert.That(ParticlePositions(root), Is.EqualTo(positions), "Fixed-seed particle replay must match forward simulation"); + } + finally + { + UnityEngine.Object.DestroyImmediate(root); + UnityEngine.Object.DestroyImmediate(cameraRoot); + } + } + + [Test] + public void PersistentGroup275KeepsEmittingPastItsOriginalRepeatInterval() + { + using var resources = Open(); + var root = new GameObject("Persistent effect test"); + try + { + var player = root.AddComponent(); + player.AutoAdvance = false; + player.Initialize(resources, 275); + player.Loop = true; + player.RepeatInterval = 9.5f; + for (int i = 0; i < 1500; i++) + player.Advance(0.02f); + Assert.That(player.Error, Is.Null); + Assert.That(player.IsAlive, Is.True); + Assert.That(player.Time, Is.EqualTo(30).Within(0.001f)); + Assert.That(player.NodeCount, Is.LessThan(150)); + } + finally + { + UnityEngine.Object.DestroyImmediate(root); + } + } + + [Test] + public void TransportSceneEffectIsIndependentOfFlexeffGroupZero() + { + using var resources = Open(); + var root = new GameObject("Transport test"); + try + { + var portal = root.AddComponent(); + portal.AutoAdvance = false; + portal.Initialize(resources); + portal.Sample(2); + Assert.That(root.GetComponentsInChildren().Sum(m => m.sharedMesh.vertexCount), Is.EqualTo(13 * 4)); + portal.Sample(1); + Vector3 vertex = root.GetComponentsInChildren().Single(m => m.name == "Transport outer cone").sharedMesh.vertices[0]; + Assert.That(vertex.x, Is.EqualTo(-1.4f * Mathf.Cos(6.4f * Mathf.Deg2Rad)).Within(0.00001f)); + Assert.That(vertex.z, Is.EqualTo(1.4f * Mathf.Sin(6.4f * Mathf.Deg2Rad)).Within(0.00001f)); + } + finally + { + UnityEngine.Object.DestroyImmediate(root); + } + } + + [Test] + public void ZeroOrbitAxisPreservesRadiusAndInvalidTasksStayDisabled() + { + using var resources = Open(); + var edits = ScriptableObject.CreateInstance(); + var root = new GameObject("Orbit semantics"); + var cameraRoot = new GameObject("Camera"); + try + { + var edit = OverrideObject(resources, 15, 0); + edits.Objects.Add(edit); + for (int i = 1; i < resources.Definitions.Groups[15].Objects.Length; i++) + edits.Objects.Add(new OriginalEffectObjectOverride { GroupId = 15, ObjectIndex = i, Hidden = true }); + edit.Definition.InitXPosition = edit.Definition.InitYPosition = edit.Definition.InitZPosition = 0; + edit.Definition.FramePerSecond = 0; + edit.Tasks = new[] { new EffectTask { IsValid = true, StartTime = -1, Duration = 10, CircleRadius = 20 } }; + var camera = cameraRoot.AddComponent(); + camera.enabled = false; + var player = root.AddComponent(); + player.AutoAdvance = false; + player.Initialize(resources, 15, overrides: edits); + player.Advance(0.04f); + player.Render(camera); + Assert.That(player.QuadCount, Is.GreaterThan(0), "A negative wait starts after init in the same update"); + player.Seek(0.2f); + player.Render(camera); + Assert.That(Vertices(root).Average(v => v.z), Is.EqualTo(1).Within(0.00001f), "Identity orbit rotation retains the radius vector"); + edit.Tasks[0].IsValid = false; + player.Seek(0.2f); + player.Render(camera); + Assert.That(player.NodeCount, Is.Zero); + Assert.That(player.QuadCount, Is.Zero); + } + finally + { + UnityEngine.Object.DestroyImmediate(root); + UnityEngine.Object.DestroyImmediate(cameraRoot); + UnityEngine.Object.DestroyImmediate(edits); + } + } + + [Test] + public void DiagnosisIncludesCallbackOverridesAndDoesNotPoisonOriginalCache() + { + using var resources = Open(); + var edits = ScriptableObject.CreateInstance(); + try + { + Assert.That(resources.Diagnose(275), Is.Empty); + var edit = OverrideObject(resources, 273, 0); + edits.Objects.Add(edit); + edit.Definition.TextureName = "effect\\missing-review-texture.tga"; + Assert.That(resources.Diagnose(275, edits).Any(e => e.Contains("missing-review-texture")), Is.True); + Assert.That(resources.Diagnose(275), Is.Empty); + edit.Hidden = true; + Assert.That(resources.Diagnose(275, edits), Is.Empty); + } + finally + { + UnityEngine.Object.DestroyImmediate(edits); + } + } + + [Test] + public void NonFiniteOverrideParametersAreRejectedBeforeSimulation() + { + using var resources = Open(); + var edits = ScriptableObject.CreateInstance(); + try + { + var edit = OverrideObject(resources, 15, 0); + edits.Objects.Add(edit); + edit.Tasks[0].SpeedX = float.NaN; + Assert.That(resources.Diagnose(15, edits).Any(e => e.Contains("finite")), Is.True); + } + finally + { + UnityEngine.Object.DestroyImmediate(edits); + } + } + + [Test] + public void TaperedMeteorStripSharesEndpointsAndInterpolatesLuminosity() + { + using var resources = Open(); + var edits = ScriptableObject.CreateInstance(); + var root = new GameObject("Meteor strip continuity"); + var cameraRoot = new GameObject("Camera"); + try + { + var edit = OverrideObject(resources, 240, 0); + edits.Objects.Add(edit); + for (int i = 1; i < resources.Definitions.Groups[240].Objects.Length; i++) + edits.Objects.Add(new OriginalEffectObjectOverride { GroupId = 240, ObjectIndex = i, Hidden = true }); + Assert.That(EffectClass.Name(edit.Definition.ClassNameCrc), Is.EqualTo("meteor")); + edit.Definition.RotationAngle = -1; + edit.Definition.Width = 20; + edit.Definition.DivisionCount = 16; + edit.Definition.BottomRadius = 10; + edit.Definition.TopRadius = 100; + edit.Definition.TextureRepeat = 255; + edit.Definition.BottomRiseSpeed = 100; + edit.Definition.TopShrink = edit.Definition.BottomShrink = 0; + edit.Tasks = new[] { new EffectTask { IsValid = true, Duration = 2 } }; + var camera = cameraRoot.AddComponent(); + camera.enabled = false; + camera.transform.position = new Vector3(0, 5, -10); + edit.Definition.InitXPosition = edit.Definition.InitYPosition = edit.Definition.InitZPosition = 0; + edit.Definition.TopRadiusIncreaseSpeed = edit.Definition.BottomRadiusIncreaseSpeed = edit.Definition.TopRiseSpeed = 0; + edit.Definition.RotationAngle = 1; + edit.Tasks[0].SpeedX = 20; + var player = root.AddComponent(); + player.AutoAdvance = false; + player.Initialize(resources, 240, overrides: edits); + player.Advance(0.04f); + player.Render(camera); + Mesh head = root.GetComponentInChildren().sharedMesh; + Assert.That(head.vertices.Average(v => v.x), Is.EqualTo(-0.1f).Within(0.00001f), "StartMeteor captures position before the task moves the emitter"); + edit.Definition.RotationAngle = -1; + edit.Tasks[0].SpeedX = 0; + player.Seek(0.5f); + player.Render(camera); + Mesh mesh = root.GetComponentsInChildren().Single(m => m.name == "Original meteor chain").sharedMesh; + Vector3[] vertices = mesh.vertices; + Color32[] colors = mesh.colors32; + Assert.That(vertices.Length, Is.GreaterThanOrEqualTo(8)); + for (int i = 0; i + 7 < vertices.Length; i += 4) + { + Assert.That(Vector3.Distance(vertices[i + 1], vertices[i + 4]), Is.LessThan(0.00001f)); + Assert.That(Vector3.Distance(vertices[i + 2], vertices[i + 7]), Is.LessThan(0.00001f)); + } + Assert.That(colors[0].a, Is.GreaterThan(colors[1].a)); + } + finally + { + UnityEngine.Object.DestroyImmediate(root); + UnityEngine.Object.DestroyImmediate(cameraRoot); + UnityEngine.Object.DestroyImmediate(edits); + } + } + + [Test] + public void ParticleEmitterRunsItsInitialCycleBeforeTheDelayedTaskEnablesLooping() + { + using var resources = Open(); + var edits = ScriptableObject.CreateInstance(); + var root = new GameObject("Initial particle cycle"); + var cameraRoot = new GameObject("Camera"); + try + { + var edit = OverrideObject(resources, 175, 0); + edits.Objects.Add(edit); + for (int i = 1; i < resources.Definitions.Groups[175].Objects.Length; i++) + edits.Objects.Add(new OriginalEffectObjectOverride { GroupId = 175, ObjectIndex = i, Hidden = true }); + var par = resources.Particle(edit.Definition.ParticleFileName); + float cycle = par.Emitters.Max(e => e.CycleMin - e.CycleMax); + edit.Tasks = new[] { new EffectTask { IsValid = true, StartTime = cycle + 1, Duration = 0.2f } }; + for (int i = 0; i < par.Emitters.Length; i++) + edits.ParticleCurves.Add(new OriginalParticleCurveOverride + { + File = edit.Definition.ParticleFileName, + Emitter = true, + Index = i, + Parameter = "Life", + Channels = Enumerable.Range(0, par.Emitters[i].Life.Channels) + .Select(_ => AnimationCurve.Constant(0, 1, 0.1f)).ToArray() + }); + var camera = cameraRoot.AddComponent(); + camera.enabled = false; + var player = root.AddComponent(); + player.AutoAdvance = false; + player.Initialize(resources, 175, overrides: edits); + player.Seek(0.1f); + player.Render(camera); + Assert.That(player.QuadCount, Is.GreaterThan(0), "The first emitter cycle does not wait for the task start time"); + player.Seek(cycle + 0.5f); + player.Render(camera); + Assert.That(player.QuadCount, Is.Zero, "The nonlooping initial cycle must stop and drain before the delayed task"); + } + finally + { + UnityEngine.Object.DestroyImmediate(root); + UnityEngine.Object.DestroyImmediate(cameraRoot); + UnityEngine.Object.DestroyImmediate(edits); + } + } + + [Test] + public void CombatParticleTaskMotionMovesTheWholeCloudInWorldSpaceParFiles() + { + using var resources = Open(); + var edits = ScriptableObject.CreateInstance(); + var root = new GameObject("Combat particle task motion"); + var cameraRoot = new GameObject("Camera"); + try + { + var edit = OverrideObject(resources, 175, 0); + edits.Objects.Add(edit); + for (int i = 1; i < resources.Definitions.Groups[175].Objects.Length; i++) + edits.Objects.Add(new OriginalEffectObjectOverride { GroupId = 175, ObjectIndex = i, Hidden = true }); + Assert.That(resources.Particle(edit.Definition.ParticleFileName).Space, Is.Zero); + edit.Tasks = new[] { new EffectTask { IsValid = true, Duration = 2 } }; + var camera = cameraRoot.AddComponent(); + camera.enabled = false; + var player = root.AddComponent(); + player.AutoAdvance = false; + player.Initialize(resources, 175, 123, edits); + player.Seek(0.5f); + player.Render(camera); + Vector3[] stationary = ParticlePositions(root); + edit.Tasks[0].SpeedX = 20; + player.Seek(0.5f); + player.Render(camera); + Vector3[] moving = ParticlePositions(root); + Assert.That(moving.Length, Is.GreaterThan(1)); + Assert.That(moving.Length, Is.EqualTo(stationary.Length)); + Vector3 translation = moving[0] - stationary[0]; + Assert.That(translation.x, Is.LessThan(-0.4f)); + for (int i = 1; i < moving.Length; i++) + Assert.That(Vector3.Distance(moving[i] - stationary[i], translation), Is.LessThan(0.00001f), "The combat wrapper translates existing particles uniformly"); + } + finally + { + UnityEngine.Object.DestroyImmediate(root); + UnityEngine.Object.DestroyImmediate(cameraRoot); + UnityEngine.Object.DestroyImmediate(edits); + } + } + + [Test] + public void FailedTextureAndModelInitializationReleaseMaterials() + { + using var resources = Open(); + var edits = ScriptableObject.CreateInstance(); + var root = new GameObject("Failed resource cleanup"); + try + { + int before = Resources.FindObjectsOfTypeAll().Length; + for (int i = 0; i < 3; i++) + Assert.Throws(() => resources.Material("effect\\missing-review-texture.tga", true, false)); + Assert.That(Resources.FindObjectsOfTypeAll().Length, Is.EqualTo(before)); + var edit = OverrideObject(resources, 173, 0); + edits.Objects.Add(edit); + Assert.That(EffectClass.Name(edit.Definition.ClassNameCrc), Is.EqualTo("model")); + edit.Definition.TextureName = "effect\\missing-review-model.cvd"; + var player = root.AddComponent(); + player.AutoAdvance = false; + for (int i = 0; i < 3; i++) + { + player.Initialize(resources, 173, overrides: edits); + Assert.That(player.Error, Is.Not.Null); + } + Assert.That(Resources.FindObjectsOfTypeAll().Length, Is.EqualTo(before)); + } + finally + { + UnityEngine.Object.DestroyImmediate(root); + UnityEngine.Object.DestroyImmediate(edits); + } + } + + [Test] + public void EditorReloadKeepsWorkingCopyAndInvalidDataPathLeavesNoBrokenProvider() + { + using var resources = Open(); + Type type = TypeCache.GetTypesDerivedFrom().FirstOrDefault(t => t.FullName == "Editor.EffectEditor"); + Assert.That(type, Is.Not.Null); + var window = (EditorWindow)ScriptableObject.CreateInstance(type); + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + void Call(string name) => type.GetMethod(name, flags).Invoke(window, null); + try + { + var working = (OriginalEffectOverrides)type.GetField("_edits", flags).GetValue(window); + working.Objects.Add(OverrideObject(resources, 15, 0)); + Call("MarkChanged"); + Assert.That(window.hasUnsavedChanges, Is.True); + Call("OnDisable"); + Call("OnEnable"); + Assert.That(type.GetField("_edits", flags).GetValue(window), Is.SameAs(working)); + Assert.That(window.hasUnsavedChanges, Is.True); + type.GetField("_dataPath", flags).SetValue(window, Path.GetFullPath("Assets")); + Call("LoadData"); + Assert.That(type.GetField("_resources", flags).GetValue(window), Is.Null); + Assert.That((string)type.GetField("_error", flags).GetValue(window), Is.Not.Empty); + } + finally + { + window.DiscardChanges(); + UnityEngine.Object.DestroyImmediate(window); + } + } + + [TestCase(60)] + [TestCase(120)] + [TestCase(144)] + public void RuntimeFrameClockMovesOnEveryFrameWhileFixedSeekingStaysRepeatable(int fps) + { + using var resources = Open(); + var edits = ScriptableObject.CreateInstance(); + var root = new GameObject("Frame clock"); + var cameraRoot = new GameObject("Camera"); + try + { + var edit = SoloObject(resources, edits, 15); + edit.Definition.FramePerSecond = 0; + edit.Tasks = new[] { new EffectTask { IsValid = true, Duration = 5, SpeedX = 20 } }; + var camera = cameraRoot.AddComponent(); + camera.enabled = false; + var player = root.AddComponent(); + player.AutoAdvance = false; + player.Initialize(resources, 15, 123, edits); + float dt = 1f / fps; + for (int i = 0; i < 4; i++) + player.AdvanceFrame(dt); + player.Render(camera); + Vector3 previous = Vertices(root)[0]; + for (int i = 0; i < fps / 2; i++) + { + player.AdvanceFrame(dt); + player.Render(camera); + Vector3 next = Vertices(root)[0]; + Assert.That(next.x - previous.x, Is.EqualTo(-dt).Within(0.00001f), "Rendering must not repeat a 50 Hz position on faster displays"); + previous = next; + } + float pausedAt = player.Time; + player.Paused = true; + player.AdvanceFrame(dt); + Assert.That(player.Time, Is.EqualTo(pausedAt)); + player.Seek(0.5f); + player.Render(camera); + Vector3[] expected = Vertices(root); + player.Seek(0.2f); + player.Seek(0.5f); + player.Render(camera); + Assert.That(Vertices(root), Is.EqualTo(expected)); + } + finally + { + UnityEngine.Object.DestroyImmediate(root); + UnityEngine.Object.DestroyImmediate(cameraRoot); + UnityEngine.Object.DestroyImmediate(edits); + } + } + + [TestCase(30)] + [TestCase(50)] + [TestCase(60)] + [TestCase(120)] + [TestCase(144)] + [TestCase(240)] + public void ModelRotationAndStretchUse60FpsReferenceAcrossFrameRatesAndSeeking(int fps) + { + using var resources = Open(); + var edits = ScriptableObject.CreateInstance(); + var root = new GameObject("Model frame rate"); + try + { + var edit = SoloObject(resources, edits, 173); + Assert.That(EffectClass.Name(edit.Definition.ClassNameCrc), Is.EqualTo("model")); + edit.Definition.InitSize = 1; + edit.Definition.InitXPosition = edit.Definition.InitYPosition = edit.Definition.InitZPosition = 0; + edit.Definition.InitXRotation = 5; + edit.Definition.InitYRotation = -10; + edit.Definition.InitZRotation = 15; + edit.Tasks = new[] { new EffectTask { IsValid = true, StartTime = -1, Duration = 5, + SpeedX = 20, SpeedY = 10, SpeedZ = 5, RotAxisX = 0.2f, RotAxisY = 0.4f, RotAxisZ = -0.1f, + XStretchSpeed = 0.01f, YStretchSpeed = 0.02f, CreateTimeSlot = -0.005f } }; + var player = root.AddComponent(); + player.AutoAdvance = false; + player.Initialize(resources, 173, 123, edits); + Assert.That(player.Error, Is.Null); + void Check(float seconds) + { + player.Render(null); + Transform model = root.transform.GetChild(0); + // Assert the chosen compatibility rate independently of + // both the model implementation and the seek step size. + float referenceFrames = seconds * 60f; + Vector3 rotation = new Vector3(5, -10, 15) + new Vector3(0.2f, 0.4f, -0.1f) * referenceFrames; + Quaternion expected = Quaternion.AngleAxis(rotation.x, Vector3.right) * + Quaternion.AngleAxis(-rotation.y, Vector3.up) * Quaternion.AngleAxis(-rotation.z, Vector3.forward); + Assert.That(Quaternion.Angle(model.localRotation, expected), Is.LessThan(0.06f), $"Rotation at {seconds}s"); + Assert.That(Vector3.Distance(model.localScale, Vector3.one + new Vector3(0.01f, 0.02f, -0.005f) * referenceFrames), + Is.LessThan(0.00005f), $"Stretch at {seconds}s"); + Assert.That(Vector3.Distance(model.localPosition, new Vector3(-1, 0.5f, 0.25f) * seconds), Is.LessThan(0.00001f)); + } + for (int i = 0; i < fps; i++) + { + player.AdvanceFrame(1f / fps); + Check(player.Time); + } + Check(1); + player.Seek(0.02f); + Check(0.02f); // One seek step applies 1.2 reference frames. + player.Seek(0.4f); + player.Seek(1); + Check(1); + player.Seek(0); + player.Speed = 2; + for (int i = 0; i < fps / 2; i++) + player.AdvanceFrame(1f / fps); + Check(1); + } + finally + { + UnityEngine.Object.DestroyImmediate(root); + UnityEngine.Object.DestroyImmediate(edits); + } + } + + [Test] + public void ModelOpacityTasksUseNormalizedAlphaAndSeparateMaterials() + { + using var resources = Open(); + var edits = ScriptableObject.CreateInstance(); + var root = new GameObject("Model opacity"); + try + { + var edit = SoloObject(resources, edits, 173); + edit.Tasks = new[] { new EffectTask { IsValid = true, StartTime = -1, Duration = 2, SpeedAlpha = -0.5f } }; + Assert.That(resources.Diagnose(173, edits), Is.Empty); + var player = root.AddComponent(); + player.AutoAdvance = false; + player.Initialize(resources, 173, overrides: edits); + player.AdvanceFrame(0.5f); + player.Render(null); + Assert.That(player.Error, Is.Null); + var property = new MaterialPropertyBlock(); + foreach (Renderer renderer in root.GetComponentsInChildren()) + { + renderer.GetPropertyBlock(property); + Assert.That(property.GetColor("_TintColor").a, Is.EqualTo(0.75f).Within(0.0001f)); + Assert.That(renderer.sharedMaterials.Length, Is.EqualTo(1)); + Assert.That(renderer.sharedMaterial.GetFloat("_Threshold"), Is.EqualTo(2)); + } + Assert.That(resources.Diagnose(173), Is.Empty); + } + finally + { + UnityEngine.Object.DestroyImmediate(root); + UnityEngine.Object.DestroyImmediate(edits); + } + } + + [Test] + public void CombatParticleScalingAndBlendFollowPal3ParticleEffectRatherThanEngineGbParticleEffect() + { + using var resources = Open(); + var edits = ScriptableObject.CreateInstance(); + var root = new GameObject("Legacy combat particle semantics"); + var cameraRoot = new GameObject("Camera"); + try + { + var edit = SoloObject(resources, edits, 175); + edit.Definition.InitXPosition = edit.Definition.InitYPosition = edit.Definition.InitZPosition = 0; + edit.Tasks = new[] { new EffectTask { IsValid = true, Duration = 2 } }; + var par = resources.Particle(edit.Definition.ParticleFileName); + foreach (var e in par.Emitters) + { + e.Shape = 0; + e.Position = e.Scope = e.BoxMin = e.BoxMax = default; + e.CycleMin = 10; + e.CycleMax = 0; + e.SpeedMultiplier = 1; + e.Direction.OverrideEvaluation = (_, channel) => channel == 0 ? 1 : 0; + e.RandomVelocity.OverrideEvaluation = (_, _) => 10; + e.Emission.OverrideEvaluation = (_, _) => 100; + e.Life.OverrideEvaluation = (_, _) => 10; + } + foreach (var p in par.Particles) + { + p.Gravity = p.Resistance = 0; + p.BlendMode = 1; + } + var camera = cameraRoot.AddComponent(); + camera.enabled = false; + var player = root.AddComponent(); + player.AutoAdvance = false; + edit.Definition.InitSize = 20; + player.Initialize(resources, 175, 123, edits); + player.Advance(0.02f); + player.Render(camera); + // pal3/effect/particle.cpp: offset = velocity * dt; offset *= + // particleMultiSize. PAL3's wrapper fixes blend to ONE / ONE. + Assert.That(ParticlePositions(root)[0].x, Is.EqualTo(-10f * 0.02f * 20 / 20).Within(0.00001f)); + Material material = root.GetComponentInChildren().sharedMaterial; + Assert.That(material.GetInt("_SrcBlend"), Is.EqualTo((int)UnityEngine.Rendering.BlendMode.One)); + Assert.That(material.GetInt("_DstBlend"), Is.EqualTo((int)UnityEngine.Rendering.BlendMode.One)); + foreach (var e in par.Emitters) + { + e.Direction.OverrideEvaluation = (_, _) => 0; + e.RandomVelocity.OverrideEvaluation = (_, _) => 0; + e.Scope = new(1, 0, 0); + } + edit.Definition.InitSize = 1; + player.Initialize(resources, 175, 123, edits); + player.Advance(0.02f); + player.Render(camera); + Vector3[] small = ParticlePositions(root); + edit.Definition.InitSize = 20; + player.Initialize(resources, 175, 123, edits); + player.Advance(0.02f); + player.Render(camera); + Vector3[] large = ParticlePositions(root); + Assert.That(large.Length, Is.GreaterThan(0)); + Assert.That(large.Length, Is.EqualTo(small.Length)); + for (int i = 0; i < small.Length; i++) + Assert.That(Vector3.Distance(large[i], small[i] * 400), Is.LessThan(0.00001f), "PAL3 scales emitter scope and then particle displacement"); + } + finally + { + UnityEngine.Object.DestroyImmediate(root); + UnityEngine.Object.DestroyImmediate(cameraRoot); + UnityEngine.Object.DestroyImmediate(edits); + } + } + + [Test] + public void UnchangedOverridesReuseParticleAndDiagnosticCachesAndEditsInvalidateBoth() + { + using var resources = Open(); + var edits = ScriptableObject.CreateInstance(); + try + { + const string path = "effect\\gbParticle\\yiwai3.par"; + var curve = new OriginalParticleCurveOverride + { + File = path, + Parameter = "Size", + Index = 0, + Channels = new[] { AnimationCurve.Constant(0, 1, 17) } + }; + edits.ParticleCurves.Add(curve); + var first = resources.Particle(path, edits); + Assert.That(resources.Particle(path, edits), Is.SameAs(first)); + curve.Channels[0] = AnimationCurve.Constant(0, 1, 19); + var second = resources.Particle(path, edits); + Assert.That(second, Is.Not.SameAs(first)); + Assert.That(second.Particles[0].Size.Evaluate(0.5f), Is.EqualTo(19)); + var edit = OverrideObject(resources, 273, 0); + edits.Objects.Add(edit); + edit.Definition.TextureName = "effect\\missing-first.tga"; + string[] diagnosis = resources.Diagnose(275, edits); + Assert.That(diagnosis, Is.Not.Empty); + Assert.That(resources.Diagnose(275, edits), Is.SameAs(diagnosis)); + edit.Definition.TextureName = "effect\\missing-second.tga"; + Assert.That(resources.Diagnose(275, edits).Any(e => e.Contains("missing-second")), Is.True); + } + finally + { + UnityEngine.Object.DestroyImmediate(edits); + } + } + + [Test] + public void SceneCacheTrimPreservesLiveParticleAssetsAndEvictsUnusedAssets() + { + using var resources = Open(); + var root = new GameObject("Live render assets"); + try + { + var player = root.AddComponent(); + player.AutoAdvance = false; + player.Initialize(resources, 175); + Material live = root.GetComponentInChildren().sharedMaterial; + Texture liveTexture = live.mainTexture; + Material unused = resources.Material("effect\\q08qiao.tga", true, false); + Texture unusedTexture = unused.mainTexture; + resources.TrimUnusedRenderAssets(new[] { root }); + Assert.That(live != null && liveTexture != null, Is.True); + Assert.That(unused == null && unusedTexture == null, Is.True); + UnityEngine.Object.DestroyImmediate(root); + resources.TrimUnusedRenderAssets(Array.Empty()); + Assert.That(live == null && liveTexture == null, Is.True); + } + finally + { + if (root != null) + UnityEngine.Object.DestroyImmediate(root); + } + } + + [Test] + public void RuntimeKeepsAttachedEffectsWhenPlayedEffectsAreCleared() + { + string path = Environment.GetEnvironmentVariable("PAL3_TEST_DATA_ROOT") ?? Path.GetFullPath("../Game Data/PAL3"); + if (!Directory.Exists(path)) + Assert.Ignore("PAL3 data is unavailable"); + var crc = new Crc32Hash(); + crc.Init(); + var fs = new CpkFileSystem(path, crc, 936); + fs.Mount("basedata/basedata.cpk", 936); + using var runtime = new Effect.EffectRuntime(fs, 936); + var scene = new GameObject("Effect scene"); + try + { + runtime.Original.Definitions.Groups[15] = new EffectGroup(); + using var attachment = runtime.Attach(15, new GameEntity(scene)); + Assert.That(runtime.Play(15, new GameEntity(scene), new Vector3(10, 0, 0)), Is.True); + var players = scene.GetComponentsInChildren(); + Assert.That(players, Has.Length.EqualTo(2)); + Assert.That(players.Single(p => p.Loop).DestroyOnComplete, Is.False); + Assert.That(players.Single(p => !p.Loop).transform.localPosition.x, Is.EqualTo(10)); + runtime.ClearPlayedEffects(); + players = scene.GetComponentsInChildren(); + Assert.That(players, Has.Length.EqualTo(1)); + Assert.That(players[0].Loop, Is.True); + attachment.Dispose(); + Assert.That(scene.transform.childCount, Is.Zero); + runtime.Dispose(); + runtime.Dispose(); + } + finally + { + UnityEngine.Object.DestroyImmediate(scene); + } + } + + [Test] + public void EditorCoalescesTimelineRequestsBeforeResimulating() + { + Type type = TypeCache.GetTypesDerivedFrom().First(t => t.FullName == "Editor.EffectEditor"); + var window = (EditorWindow)ScriptableObject.CreateInstance(type); + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + try + { + var player = (OriginalEffectPlayer)type.GetField("_player", flags).GetValue(window); + Assert.That(player, Is.Not.Null); + type.GetMethod("QueueSeek", flags).Invoke(window, new object[] { 1f }); + type.GetMethod("QueueSeek", flags).Invoke(window, new object[] { 2f }); + Assert.That(player.Time, Is.Zero, "Input events only queue the latest timeline request"); + type.GetField("_seekAfter", flags).SetValue(window, 0d); + type.GetMethod("Tick", flags).Invoke(window, null); + Assert.That(player.Time, Is.EqualTo(2).Within(0.00001f)); + } + finally + { + window.DiscardChanges(); + UnityEngine.Object.DestroyImmediate(window); + } + } + + private static OriginalEffectObjectOverride SoloObject(OriginalEffectResources resources, OriginalEffectOverrides edits, int group) + { + var edit = OverrideObject(resources, group, 0); + edits.Objects.Add(edit); + for (int i = 1; i < resources.Definitions.Groups[group].Objects.Length; i++) + edits.Objects.Add(new OriginalEffectObjectOverride { GroupId = group, ObjectIndex = i, Hidden = true }); + return edit; + } + + private static OriginalEffectObjectOverride OverrideObject(OriginalEffectResources resources, int group, int index) + { + EffectGroupObject source = resources.Definitions.Groups[group].Objects[index]; + resources.Definitions.TryGetType(source.TypeCrc, out EffectDefinition definition); + return new OriginalEffectObjectOverride + { + GroupId = group, + ObjectIndex = index, + Definition = JsonUtility.FromJson(JsonUtility.ToJson(definition)), + Tasks = source.Tasks.Select(t => JsonUtility.FromJson(JsonUtility.ToJson(t))).ToArray() + }; + } + + private static Vector3[] Vertices(GameObject root) => root.GetComponentsInChildren().SelectMany(m => m.sharedMesh.vertices).ToArray(); + + private static Vector3[] ParticlePositions(GameObject root) => root.GetComponentsInChildren().SelectMany(ps => + { + var particles = new ParticleSystem.Particle[ps.particleCount]; + ps.GetParticles(particles); + return particles.Select(p => p.position); + }).ToArray(); + } +} diff --git a/Assets/Tests/Game/Editor/OriginalEffectTests.cs.meta b/Assets/Tests/Game/Editor/OriginalEffectTests.cs.meta new file mode 100644 index 000000000..3dd8beddb --- /dev/null +++ b/Assets/Tests/Game/Editor/OriginalEffectTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 100011eb82a8643f695ea64a052075b4 \ No newline at end of file diff --git a/Assets/Tests/Game/Editor/OriginalPal3AEffectTests.cs b/Assets/Tests/Game/Editor/OriginalPal3AEffectTests.cs new file mode 100644 index 000000000..8c29f946f --- /dev/null +++ b/Assets/Tests/Game/Editor/OriginalPal3AEffectTests.cs @@ -0,0 +1,228 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Pal3.Game.Tests +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using Core.DataReader.Cpk; + using Core.FileSystem; + using Core.DataReader.Data; + using Effect.Original; + using NUnit.Framework; + using UnityEngine; + + [Category("LocalGameData")] + public sealed class OriginalPal3AEffectTests + { + public static readonly int[] NativeGroups = { 9, 27, 29, 34, 40, 50, 91, 157, 160, 172, 173, 175, 176, 177, 178, 179, 181, 182, 186, 187, 189, 194, 198, 201, 202, 205, 206, 209, 217, 230, 231, 254, 257, 258, 259, 261, 281, 282, 283, 285, 287, 290, 292, 297, 299, 314, 316, 320, 322, 330, 331, 333, 335, 351, 355, 356, 359, 362, 365, 373, 377, 381, 385, 388, 389, 400, 424 }; + public static readonly int[] SparGroups = { 45, 164, 170, 171, 174, 211, 289, 315, 318, 323, 325, 328, 344, 345, 358, 360, 361, 367, 397, 401, 404, 407, 408, 409, 411, 570 }; + + private static OriginalEffectResources Open() + { + string path = Environment.GetEnvironmentVariable("PAL3A_TEST_DATA_ROOT") ?? Path.GetFullPath("../Game Data/PAL3A"); + if (!Directory.Exists(path)) + Assert.Ignore("Set PAL3A_TEST_DATA_ROOT to the local PAL3A data directory"); + var crc = new Crc32Hash(); + crc.Init(); + var fs = new CpkFileSystem(path, crc, 936); + fs.Mount("basedata/basedata.cpk", 936); + return new OriginalEffectResources(fs); + } + + [Test] + public void AllNonSparScriptAndSceneGroupsSimulateWithOriginalData() + { + using var resources = Open(); + Assert.That(resources.GameVariant, Is.EqualTo("PAL3A")); + Assert.That(resources.OverridesPath, Is.EqualTo("Effects/PAL3AOverrides")); + var errors = new List(); + foreach (int id in NativeGroups) + { + var root = new GameObject($"PAL3A effect {id}"); + try + { + Assert.That(resources.Diagnose(id), Is.Empty, $"Group {id}"); + var player = root.AddComponent(); + player.AutoAdvance = false; + player.Initialize(resources, id, 1234); + bool rendered = false; + for (int i = 0; i < 650; i++) + { + player.Advance(0.02f); + if (i % 10 == 0) + { + player.Render(null); + rendered |= player.QuadCount > 0 || root.GetComponentsInChildren().Any(r => r.enabled); + } + Assert.That(player.Error, Is.Null, $"Group {id}"); + } + Assert.That(rendered, Is.True, $"Group {id}: no geometry"); + player.Seek(1); + player.Render(null); + Assert.That(player.Error, Is.Null); + } + catch (Exception ex) + { + errors.Add($"{id}: {ex.Message}"); + } + finally + { + UnityEngine.Object.DestroyImmediate(root); + } + } + Assert.That(errors, Is.Empty, string.Join("\n", errors)); + } + + [Test] + public void SparBillboardUsesNativeViewRotationAndUvCorners() + { + using var resources = Open(); + var source = resources.Definitions.Groups[170].Objects[0]; + resources.Definitions.TryGetType(source.TypeCrc, out EffectDefinition definition); + definition.InitXPosition = definition.InitYPosition = definition.InitZPosition = 0; + definition.InitXRotation = definition.InitYRotation = definition.InitZRotation = 0; + definition.InitSize = 100; + var file = resources.Spar(definition.ParticleFileName); + var group = file.Groups[0]; + var key = OriginalSparTests.Key(); + key.RotationMinimum = key.RotationMaximum = Mathf.PI / 2; + group.Keyframes = new[] { key }; + group.TextureFrames = new[] { new[] { 0.2f, 0.3f, 0.7f, 0.8f } }; + file.Groups = new[] { group }; + var root = new GameObject("SPar native billboard"); + var task = new EffectTask { StartTime = -1, Duration = 10, SpeedX = 10, RotAxisZ = 900, XStretchSpeed = 100, SpeedAlpha = -255 }; + var node = new OriginalSparNode(root, definition, new[] { task }, Vector3.zero, 0, resources, 1); + try + { + node.Step(0.02f, 0.02f); + node.Step(0.04f, 0.02f); + node.Render(null); + Mesh mesh = root.GetComponentInChildren().sharedMesh; + Vector3[] vertices = mesh.vertices; + // Native 0x4c8eb6..0x4c8ff6 uses positive view-space radians. + // Its top-left UV is (su,sv); Unity's bottom-left is (su,1-ev). + Assert.That(vertices[0].x, Is.EqualTo(0.08f).Within(0.000001)); + Assert.That(vertices[0].y, Is.EqualTo(-0.1f).Within(0.000001)); + Assert.That(Vector3.Distance(vertices[0], vertices[1]), Is.EqualTo(0.2f).Within(0.000001)); + Assert.That(mesh.uv[0].x, Is.EqualTo(0.2f).Within(0.000001)); + Assert.That(mesh.uv[0].y, Is.EqualTo(0.2f).Within(0.000001)); + Assert.That(mesh.uv[2].y, Is.EqualTo(0.7f).Within(0.000001)); + int[] indices = mesh.triangles; + Assert.That(Vector3.Cross(vertices[indices[1]] - vertices[indices[0]], vertices[indices[2]] - vertices[indices[0]]).z, Is.LessThan(0)); + } + finally + { + node.Dispose(); + } + } + + [TestCase(0, "trans.tga", 245, 188, 28)] + [TestCase(1, "trans_s.tga", 0, 238, 255)] + [TestCase(2, "trans_m.tga", 246, 0, 208)] + [TestCase(3, "trans_l.tga", 108, 0, 255)] + [TestCase(4, "trans_f.tga", 0, 255, 112)] + [TestCase(5, "trans_t.tga", 255, 218, 0)] + [TestCase(6, "trans_p.tga", 255, 0, 0)] + public void TransportUsesOriginalStyleWithoutLoadingFlexeffZero(int type, string texture, int red, int green, int blue) + { + using var resources = Open(); + var root = new GameObject("PAL3A transport"); + try + { + var portal = root.AddComponent(); + portal.AutoAdvance = false; + portal.Initialize(resources, (uint)type); + portal.Sample(2); + MeshFilter[] meshes = root.GetComponentsInChildren(); + Assert.That(meshes.Select(m => m.sharedMesh.vertexCount), Is.EqualTo(new[] { 4, 24, 24 })); + Assert.That(meshes[0].GetComponent().sharedMaterial.mainTexture.name, Does.EndWith(texture)); + Assert.That(meshes[1].sharedMesh.colors32[0], Is.EqualTo(new Color32((byte)red, (byte)green, (byte)blue, 255))); + Assert.That(root.GetComponentsInChildren(), Is.Empty); + } + finally + { + UnityEngine.Object.DestroyImmediate(root); + } + } + + [TestCase(1, UnityEngine.Rendering.BlendMode.SrcAlpha, UnityEngine.Rendering.BlendMode.DstAlpha)] + [TestCase(2, UnityEngine.Rendering.BlendMode.SrcAlpha, UnityEngine.Rendering.BlendMode.OneMinusSrcColor)] + [TestCase(3, UnityEngine.Rendering.BlendMode.SrcColor, UnityEngine.Rendering.BlendMode.OneMinusSrcColor)] + [TestCase(0, UnityEngine.Rendering.BlendMode.One, UnityEngine.Rendering.BlendMode.Zero)] + public void SparMaterialMatchesOriginalGbfAndSurvivesLiveCacheTrim(int shader, UnityEngine.Rendering.BlendMode source, UnityEngine.Rendering.BlendMode destination) + { + using var resources = Open(); + Material material = resources.SparMaterial("effect\\trans.tga", shader); + Assert.That(material.GetInt("_SrcBlend"), Is.EqualTo((int)source)); + Assert.That(material.GetInt("_DstBlend"), Is.EqualTo((int)destination)); + Assert.That(material.GetInt("_AlphaMode"), Is.Zero); + var root = new GameObject("SPar cache lifetime"); + try + { + root.AddComponent().sharedMaterial = material; + resources.TrimUnusedRenderAssets(new[] { root }); + Assert.That(material != null && material.mainTexture != null, Is.True); + } + finally + { + UnityEngine.Object.DestroyImmediate(root); + } + resources.TrimUnusedRenderAssets(Array.Empty()); + Assert.That(material == null, Is.True); + } + + [TestCaseSource(nameof(SparGroups))] + public void RemainingScriptAndSceneGroupSimulatesRendersAndSeeks(int id) + { + using var resources = Open(); + Assert.That(resources.Diagnose(id), Is.Empty, $"Group {id}"); + var root = new GameObject($"SPar group {id}"); + try + { + var player = root.AddComponent(); + player.AutoAdvance = false; + player.Initialize(resources, id, 1234); + int maximum = 0; + float sampleTime = 0; + bool sparRendered = false; + for (int i = 0; i < 650; i++) + { + player.Advance(0.02f); + if (i % 10 == 0) + { + player.Render(null); + maximum = Math.Max(maximum, player.QuadCount); + bool hasSpar = root.GetComponentsInChildren().Any(r => r.enabled && r.sharedMaterial.shader.name == "Pal3/OriginalEffect" && r.sharedMaterial.GetInt("_Cull") == 2); + if (sampleTime == 0 && hasSpar) + sampleTime = player.Time; + sparRendered |= hasSpar; + foreach (MeshFilter mesh in root.GetComponentsInChildren()) + Assert.That(mesh.sharedMesh.vertices.All(v => float.IsFinite(v.x) && float.IsFinite(v.y) && float.IsFinite(v.z)), Is.True); + } + Assert.That(player.Error, Is.Null); + } + Assert.That(maximum, Is.GreaterThan(0), $"Group {id}: no geometry"); + Assert.That(sparRendered, Is.True, $"Group {id}: SPar did not submit visible particles"); + TestContext.WriteLine($"GPU sample: {id} {sampleTime:R}"); + player.Seek(sampleTime); + player.Render(null); + Vector3[] first = root.GetComponentsInChildren().SelectMany(m => m.sharedMesh.vertices).ToArray(); + Assert.That(first, Is.Not.Empty); + player.Seek(sampleTime + 2); + player.Seek(sampleTime); + player.Render(null); + Assert.That(root.GetComponentsInChildren().SelectMany(m => m.sharedMesh.vertices).ToArray(), Is.EqualTo(first)); + Assert.That(player.Error, Is.Null); + } + finally + { + UnityEngine.Object.DestroyImmediate(root); + } + } + } +} diff --git a/Assets/Tests/Game/Editor/OriginalPal3AEffectTests.cs.meta b/Assets/Tests/Game/Editor/OriginalPal3AEffectTests.cs.meta new file mode 100644 index 000000000..74a36fd1f --- /dev/null +++ b/Assets/Tests/Game/Editor/OriginalPal3AEffectTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7c0b693015b546b089454e960698be7a diff --git a/Assets/Tests/Game/Editor/OriginalSparTests.cs b/Assets/Tests/Game/Editor/OriginalSparTests.cs new file mode 100644 index 000000000..9dd3f0fd0 --- /dev/null +++ b/Assets/Tests/Game/Editor/OriginalSparTests.cs @@ -0,0 +1,203 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Pal3.Game.Tests +{ + using System; + using System.Linq; + using Core.DataReader.Par; + using Core.Primitives; + using Effect.Original; + using NUnit.Framework; + using UnityEngine; + + public sealed class OriginalSparTests + { + internal static SparKeyframe Key(float time = 0) + { + return new SparKeyframe + { + Time = time, + Position = new GameBoxVector3(100, 200, 300), + EmissionMinimum = 0, + EmissionMaximum = 0, + ShapeParameters = Array.Empty(), + PathPoints = Array.Empty(), + LifeMinimum = 10, + LifeMaximum = 10, + SizeMinimum = 2, + SizeMaximum = 2, + AlphaMinimum = 1, + AlphaMaximum = 1, + ColorMinimum = new GameBoxVector3(1, 1, 1), + ColorMaximum = new GameBoxVector3(1, 1, 1), + VelocityMinimum = new GameBoxVector3(10, 0, 0), + VelocityMaximum = new GameBoxVector3(10, 0, 0), + Controllers = Enumerable.Range(0, 6).Select(_ => new SparController { Parameters = Array.Empty() }).ToArray() + }; + } + + private static SparGroup Group(params SparKeyframe[] keys) => new() + { + Capacity = 2, + VertexCapacity = 12, + Keyframes = keys, + TextureFrames = new[] { new[] { 0f, 0f, 0.5f, 1f }, new[] { 0.5f, 0f, 1f, 1f } } + }; + + [Test] + public void NativeRateOverridesFileAndUpdatesNewUnitsBeforeRendering() + { + SparKeyframe key = Key(); + key.Controllers[0].Kind = 1; + var emitter = new OriginalSparEmitter(Group(key), 1); + emitter.Step(0.1f); + Assert.That(emitter.Count, Is.EqualTo(2)); + Assert.That(emitter.Units[0].Age, Is.EqualTo(0.1f)); + Assert.That(emitter.Units[0].Position, Is.EqualTo(new Vector3(1, 0, 0))); + emitter.Stop(); + Assert.That(emitter.Count, Is.Zero); + emitter.Start(); + emitter.Step(0.1f); + Assert.That(emitter.Units[0].Position.x, Is.EqualTo(1)); + } + + [TestCase(2, 2, 0, 0, 1.02f)] + [TestCase(3, 2, 0, 0, 0.918f)] + public void ForceAndSizeDependentDragUseSemiImplicitEuler(int kind, float force, float y, float z, float expectedX) + { + SparKeyframe key = Key(); + key.Controllers[0] = new SparController { Kind = kind, Parameters = new[] { force, y, z, 0.5f } }; + var emitter = new OriginalSparEmitter(Group(key), 123); + emitter.Step(0.1f); + Assert.That(emitter.Units[0].Position.x, Is.EqualTo(expectedX).Within(0.000001)); + } + + [TestCase(4, 3f)] + [TestCase(5, 3.01f)] + [TestCase(6, 3.99f)] + public void AttractionUsesCurrentAgeAndKeepsTheAuthoredVelocity(int kind, float expectedX) + { + SparKeyframe key = Key(); + key.Shape = 2; + key.ShapeParameters = new[] { 4f, 0, 0, 4f, 0, 0 }; + key.Controllers[0].Kind = kind; + var emitter = new OriginalSparEmitter(Group(key), 1); + emitter.Step(0.1f); + Assert.That(emitter.Units[0].Position.x, Is.EqualTo(expectedX).Within(0.000001)); + Assert.That(emitter.Units[0].Velocity.x, Is.EqualTo(10)); + } + + [Test] + public void PathsAdvanceByDiscreteIndexAndDiscardOverflow() + { + SparKeyframe key = Key(100); + key.HasPath = true; + key.PathRate = 10; + key.PathPoints = new[] { new GameBoxVector3(2, 0, 0), new GameBoxVector3(8, 0, 0) }; + SparGroup group = Group(key); + group.Capacity = 10; + var emitter = new OriginalSparEmitter(group, 1); + emitter.Step(0.1f); + Assert.That(emitter.Units[0].Position.x, Is.EqualTo(8)); + emitter.Stop(); + emitter.Start(); + emitter.Step(0.5f); + Assert.That(emitter.Units[0].Position.x, Is.EqualTo(2)); + } + + [Test] + public void PointShapeRotationUsesRadiansAndDoesNotRotateVelocity() + { + SparKeyframe key = Key(100); + key.Shape = 3; + key.ShapeParameters = new[] { 1f, 0, 0 }; + key.Controllers[5] = new SparController { Kind = 1, Parameters = new[] { 0f, 0, 1, Mathf.PI } }; + var emitter = new OriginalSparEmitter(Group(key), 1); + emitter.Step(0.5f); + Assert.That(emitter.Units[0].Position.x, Is.EqualTo(0).Within(0.000001)); + Assert.That(emitter.Units[0].Position.y, Is.EqualTo(1).Within(0.000001)); + Assert.That(emitter.Units[0].Velocity.x, Is.EqualTo(10)); + } + + [Test] + public void KeyframeBoundaryKeepsLeftControllersAndLoopDropsOvershoot() + { + SparKeyframe a = Key(), b = Key(1), c = Key(2); + a.Controllers[0].Kind = 1; + b.SizeMinimum = b.SizeMaximum = 6; + SparGroup group = Group(a, b, c); + group.Loop = true; + var emitter = new OriginalSparEmitter(group, 1); + emitter.Step(1); + Assert.That(emitter.Units[0].Size, Is.EqualTo(6)); + Assert.That(emitter.Units[0].Position.x, Is.EqualTo(10)); + emitter.Step(0.1f); + Assert.That(emitter.Units[0].Position.x, Is.EqualTo(10)); + emitter.Step(1.2f); + Assert.That(emitter.Time, Is.Zero); + } + + [TestCase(1, 1)] + [TestCase(2, 0)] + public void TextureFramesClampOrLoopFromAgeWithoutAnInitialFrameOffset(int kind, int expectedFrame) + { + SparKeyframe key = Key(); + key.FrameMinimum = key.FrameMaximum = 1; + key.Controllers[4] = new SparController { Kind = kind, Parameters = new[] { 0.125f } }; + var emitter = new OriginalSparEmitter(Group(key), 1); + emitter.Step(0.5f); + Assert.That(emitter.Units[0].Frame, Is.EqualTo(expectedFrame)); + } + + [TestCase(0)] + [TestCase(2)] + public void NonzeroKeyframeFrameBoundsDoNotReachTheNativeLiveEmitter(int frameController) + { + // Independent native execution of 0x4c5efd -> 0x4c8509 -> 0x4c5fea + // keeps live +cc/+d0 at zero despite serialized ranges 5..15/9..13. + SparKeyframe first = Key(), last = Key(0.04f); + first.FrameMinimum = 5; + first.FrameMaximum = 15; + last.FrameMinimum = 9; + last.FrameMaximum = 13; + foreach (SparKeyframe key in new[] { first, last }) + key.Controllers[4] = new SparController { Kind = frameController, Parameters = new[] { 0.125f } }; + SparGroup group = Group(first, last); + group.Capacity = 64; + group.TextureFrames = Enumerable.Range(0, 16).Select(i => new[] { i / 16f, 0, (i + 1) / 16f, 1 }).ToArray(); + var emitter = new OriginalSparEmitter(group, 1); + for (int i = 0; i < 3; i++) + emitter.Step(0.02f); + Assert.That(emitter.Count, Is.GreaterThan(20)); + Assert.That(emitter.Units.Take(emitter.Count).Select(u => u.Frame), Is.All.Zero); + Assert.That(new[] { first.FrameMinimum, first.FrameMaximum, last.FrameMinimum, last.FrameMaximum }, Is.EqualTo(new[] { 5, 15, 9, 13 })); + } + + [Test] + public void FadeAndColorControllersReplaceInitialValuesUsingLifetimeFraction() + { + SparKeyframe key = Key(); + key.LifeMinimum = key.LifeMaximum = 1; + key.Controllers[2] = new SparController { Kind = 2, Parameters = new[] { 1f, 0, 0, 0, 0, 1 } }; + key.Controllers[3].Kind = 1; + var emitter = new OriginalSparEmitter(Group(key), 1); + emitter.Step(0.25f); + Assert.That(emitter.Units[0].Alpha, Is.EqualTo(0.75f)); + Assert.That(emitter.Units[0].Color, Is.EqualTo(new Vector3(0.75f, 0, 0.25f))); + } + + [Test] + public void NativeSwapRemovalSkipsTheMovedUnitUntilTheNextFrame() + { + SparKeyframe key = Key(); + key.LifeMinimum = key.LifeMaximum = 0.01f; + var emitter = new OriginalSparEmitter(Group(key), 1); + emitter.Step(0.1f); + Assert.That(emitter.Count, Is.EqualTo(1)); + Assert.That(emitter.Units[0].Age, Is.Zero); + } + } +} diff --git a/Assets/Tests/Game/Editor/OriginalSparTests.cs.meta b/Assets/Tests/Game/Editor/OriginalSparTests.cs.meta new file mode 100644 index 000000000..9690ef6a5 --- /dev/null +++ b/Assets/Tests/Game/Editor/OriginalSparTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 87a06536f27c4b63a08c3a793457e8e6 diff --git a/Assets/Tests/Game/Editor/Pal3.Game.Tests.asmdef b/Assets/Tests/Game/Editor/Pal3.Game.Tests.asmdef index 4a944374f..0b6977d6b 100644 --- a/Assets/Tests/Game/Editor/Pal3.Game.Tests.asmdef +++ b/Assets/Tests/Game/Editor/Pal3.Game.Tests.asmdef @@ -1,6 +1,6 @@ { "name": "Pal3.Game.Tests", - "references": ["Pal3.Core", "Pal3.Game"], + "references": ["Pal3.Core", "Pal3.Game", "Engine"], "includePlatforms": ["Editor"], "optionalUnityReferences": ["TestAssemblies"], "autoReferenced": false, diff --git a/Assets/Tests/Game/Editor/ReleaseArchiveTests.cs b/Assets/Tests/Game/Editor/ReleaseArchiveTests.cs new file mode 100644 index 000000000..cb36c62ef --- /dev/null +++ b/Assets/Tests/Game/Editor/ReleaseArchiveTests.cs @@ -0,0 +1,44 @@ +// ------------------------------------------------------------------------ +// Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// ------------------------------------------------------------------------ + +namespace Pal3.Game.Tests +{ + using System; + using System.IO; + using System.IO.Compression; + using System.Linq; + using System.Reflection; + using Constants; + using NUnit.Framework; + using UnityEditor; + + public sealed class ReleaseArchiveTests + { + [Test] + public void ArchiveUsesTheCurrentVersionDirectoryAndOriginalFileLayout() + { + string directory = Path.Combine(Path.GetTempPath(), "Pal3ReleaseTest-" + Guid.NewGuid().ToString("N")); + string output = Path.Combine(directory, PlayerSettings.bundleVersion); + string playerPath = Path.Combine(output, GameConstants.AppName, "Windows_x64", GameConstants.AppName + ".exe"); + try + { + Directory.CreateDirectory(Path.GetDirectoryName(playerPath)); + File.WriteAllBytes(playerPath, new byte[] { 1, 2, 3 }); + Type pipeline = AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetType("Editor.ReleaseBuildPipeline")).First(t => t != null); + MethodInfo zip = pipeline.GetMethod("ZipReleaseFiles", BindingFlags.Static | BindingFlags.NonPublic, null, new[] { typeof(string) }, null); + zip.Invoke(null, new object[] { output }); + string archive = Path.Combine(output, "Release", $"{GameConstants.AppName}_v{PlayerSettings.bundleVersion}_Windows_x64.zip"); + Assert.That(File.Exists(archive), Is.True); + using var contents = ZipFile.OpenRead(archive); + Assert.That(contents.Entries.Select(e => e.FullName), Is.EqualTo(new[] { GameConstants.AppName + ".exe" })); + } + finally + { + if (Directory.Exists(directory)) + Directory.Delete(directory, true); + } + } + } +} diff --git a/Assets/Tests/Game/Editor/ReleaseArchiveTests.cs.meta b/Assets/Tests/Game/Editor/ReleaseArchiveTests.cs.meta new file mode 100644 index 000000000..75081c95a --- /dev/null +++ b/Assets/Tests/Game/Editor/ReleaseArchiveTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8580799c18e841bba1c2cf6e2f92a52e diff --git a/README.en-US.md b/README.en-US.md index f0e1d1cc8..e6734613a 100644 --- a/README.en-US.md +++ b/README.en-US.md @@ -71,9 +71,11 @@ Both games default to the original visual style in URP, preserving the original The visual effects settings provide independent switches for contact shadows, character lighting, enhanced night fire lighting, and fire shadows. Contact shadows and fire shadows default to enabled on desktop devices. Enhanced fire lighting combines warm lights with a cool ambient tone. Fire shadows also work in dark maze scenes. Desktop defaults to four shadowed fires. Set `Settings.ToonFireShadowBudget` to 0–4 in the debug console. The shadow atlas grows with the budget. Bloom has a separate switch and defaults to enabled only for the original style on desktop. -The screenshots above show the previous toon renderer. Optional story VFX are off by default. Install URP-compatible numbered prefabs locally in `Assets/Resources/Prefabs/VFX/PAL3/` or `Assets/Resources/Prefabs/VFX/PAL3A/`, then enable optional story VFX in the visual effects settings. Turning the setting off hides active optional effects immediately. Turning it back on restores persistent effects in the current scene and shows newly triggered story effects. +The screenshots above show the previous toon renderer. Supported story and scene effects in both games use the original data: effect groups, tasks, particles, textures, and models loaded from local CPK archives, rendered with this project's URP shaders and Unity components. Open `PAL3 / PAL3A → Effect Editor` in Unity to select a game, play effects, inspect objects, and edit group tasks and legacy particle curves. Save overrides to `Assets/Resources/Effects/PAL3Overrides.asset` or `PAL3AOverrides.asset` for use in the game. ResourceViewer provides an original effects panel and the commands `Effect.Load `, `Effect.Seek `, and `Effect.Pause true/false`. Group ID `-1` supports transport points in both games. -Use a fresh checkout for public builds. Unity blocks player builds when it finds local optional VFX prefabs, Hovl Studio assets, RealToon assets, or legacy toon materials. To build for private use, launch your local Unity editor with `PAL3_ALLOW_PRIVATE_ASSETS=1`. The project build menu places these builds in a version folder with the `-Private` suffix. +The current inventory covers all 56 PAL3 and 93 PAL3A story and scene root groups using original data. Transport points reproduce the original disc, two light cones, and colors selected by scene parameters. SPar keyframes, paths, six controller slots, and material blending are implemented independently; raw SPar fields are read-only in the editor. Unsupported types such as battle directors produce diagnostics and will be added alongside their related systems. + +All implementation code, shaders, and build resources are included in this repository. The game reads the player's local original game data at runtime; that data is not distributed with this project. ## Controls - Mouse and keyboard: Left mouse button to click and control character movement (arrow keys can also be used), A/D to control camera rotation, Spacebar to interact with nearby items or NPCs (also used as the jump button when available), M key to toggle the big map, U key or ESC key to open the story selection menu, Tab key to switch characters (in the maze). diff --git a/README.md b/README.md index d78fa538e..a176b584c 100644 --- a/README.md +++ b/README.md @@ -70,9 +70,11 @@ 「游戏设置 → 画面与特效设置」提供接触阴影、角色光照、增强夜景火光和火光投影四个独立开关,在卡通画风中即时生效。接触阴影和火光投影在桌面设备默认开启,手持设备可按需开启。夜景火光使用暖色灯光与冷色环境底色。火光投影也适用于暗色迷宫,桌面默认四盏,控制台可通过 `Settings.ToonFireShadowBudget` 设置 0–4 盏,阴影图集随预算扩容。泛光可以独立开关,默认仅桌面原版画风开启。 -上方截图来自旧版卡通渲染。可选剧情特效默认关闭。请在本地安装与 URP 兼容的编号预制体到 `Assets/Resources/Prefabs/VFX/PAL3/` 或 `Assets/Resources/Prefabs/VFX/PAL3A/`,再启用「可选剧情特效」。关闭时会立即隐藏当前可选特效。重新启用后,仍在场景中的持续特效会恢复,后续触发的剧情特效会照常显示。 +上方截图来自旧版卡通渲染。两作已支持的剧情与场景特效由原版数据驱动,运行时读取本地 CPK 的特效组、任务、粒子、贴图和模型,使用自有 URP shader 与 Unity 原生渲染组件。Unity 菜单 `PAL3 / PAL3A → Effect Editor` 可选择作品、播放、检查对象、修改组任务和旧粒子曲线;覆盖资产分别保存到 `Assets/Resources/Effects/PAL3Overrides.asset` 或 `PAL3AOverrides.asset` 后供游戏读取。ResourceViewer 提供原版特效面板及 `Effect.Load <组ID>`、`Effect.Seek <秒>`、`Effect.Pause true/false` 命令;组 ID `-1` 支持 PAL3/PAL3A 两作传送点。 -公开构建请使用未导入本地付费素材的全新检出。若工程中有可选特效预制体、Hovl Studio、RealToon 或旧版卡通材质,Unity 会阻止构建。仅供个人使用的构建可通过环境变量 `PAL3_ALLOW_PRIVATE_ASSETS=1` 启动本地 Unity 来显式启用。工程打包菜单会将这类构建输出到带 `-Private` 后缀的版本目录。 +PAL3 已覆盖当前清单中的 56 个剧情/场景根组,PAL3A 已覆盖 93 个,均由原版数据驱动。传送点按原版圆盘、双光锥及场景参数配色播放。SPar 关键帧、路径、六槽控制器和材质混合独立实现,原始 SPar 字段在编辑器中只读。尚未实现的战斗导演等类型会给出诊断,留待对应系统开发时补齐。 + +实现所需的代码、Shader 和构建资源均随本仓库提供。运行时读取玩家本地的原版游戏数据;原版数据不随项目分发。 ## 按键以及操作 - 鼠标键盘:鼠标左键点击操作人物行走方向(键盘方向键也可以),AD控制镜头旋转,空格与周边附近的物品或者NPC交互(也是跳跃键),M键打开大地图,U键或ESC键打开剧情选择菜单,Tab键切换角色(迷宫中)。 @@ -83,6 +85,15 @@ 仙剑奇侠传三以及仙剑奇侠传三外传的剧情部分已经全部完成,两部游戏都可以完整的从头玩到尾体验一遍剧情,也可以使用游戏内提供的剧情选择菜单跳转至预设好的剧情时间点。两部游戏中的迷宫机关也基本全部实现,剧情中出现的大部分特效也已经重新实现。但是战斗,小游戏以及围绕支持战斗系统的各类系统和功能均尚未完成。另外主菜单和大部分界面也需要重新设计和实现(毕竟现在还要做手机端的适配)。 ## 如何贡献? +修改自有 C# 代码后,使用 `global.json` 指定的 .NET SDK,在工程根目录执行: + +```sh +python3 tools/format-csharp.py +python3 tools/format-csharp.py --check +``` + +工具使用 SDK 自带的 `dotnet format`、`.editorconfig` 和固定版本的 StyleCop 布局规则,检查空格、缩进、函数/成员间空行及多条语句分行。默认只应用改动行及删除边界上的格式修正,保留其他行的既有格式;显式路径或 `--all` 可检查整个文件。工具分别检查 PAL3/PAL3A 条件分支,不需要 Unity 或游戏数据;StyleCop 仅用于开发工具,不进入 Unity 工程。PR 的 `Code style` 工作流执行相同检查。格式检查通过后仍需运行相关 Unity 测试;ShaderLab/HLSL 由人工整理并验证。 + 因为项目还处于早期实现过程中,很多系统还没有实现,暂时不接受比较大的Pull request,特别是feature类型,如果您有好的想法,意见或者发现了Bug请欢迎提交issue或者加入交流群与我讨论。 另外您还可以参考这个项目解析视频:https://www.bilibili.com/video/BV1Pr4y167sF diff --git a/global.json b/global.json new file mode 100644 index 000000000..b14862c49 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.403", + "rollForward": "disable" + } +} diff --git a/tools/format-csharp.py b/tools/format-csharp.py new file mode 100644 index 000000000..1c7af6491 --- /dev/null +++ b/tools/format-csharp.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +# ------------------------------------------------------------------------ +# Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +# See LICENSE file in the project root for license information. +# ------------------------------------------------------------------------ + +"""Format owned C# changes with Roslyn and targeted StyleCop layout rules.""" + +import argparse +import difflib +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import xml.etree.ElementTree as ET + + +ROOT = Path(__file__).resolve().parents[1] +OWNED = ("Assets/Scripts/", "Assets/Tests/") +LAYOUT_RULES = ("SA1107", "SA1502", "SA1507", "SA1516") + + +def git_files(*args): + result = subprocess.check_output(["git", "-C", str(ROOT), *args, "-z"]) + return {os.fsdecode(name) for name in result.split(b"\0") if name} + + +def select_formatting(baseline, original, formatted): + """Keep formatter edits touching changed lines or a deletion boundary.""" + if baseline is None: + return formatted + # Existing files may predate the current CRLF policy. Keep their line-ending + # convention instead of converting the whole file or introducing mixed EOLs. + newline = b"\r\n" if original.count(b"\r\n") > original.count(b"\n") / 2 else b"\n" + formatted = formatted.replace(b"\r\n", b"\n").replace(b"\n", newline) + before = original.splitlines(keepends=True) + after = formatted.splitlines(keepends=True) + content = lambda lines: [line.rstrip(b"\r\n") for line in lines] + old_source = baseline.splitlines(keepends=True) + old_lines = content(old_source) + current_lines = content(before) + dirty = set() + boundaries = set() + for tag, old_start, _, start, end in difflib.SequenceMatcher( + None, old_lines, current_lines, autojunk=False).get_opcodes(): + if tag == "equal": + dirty.update(index for index in range(start, end) + if before[index].endswith(b"\n") != + old_source[old_start + index - start].endswith(b"\n")) + else: + dirty.update(range(start, end)) + if start == end: + boundaries.add(start) + result = [] + # Match surviving lines independently of spacing so an inserted member gap + # does not pull an adjacent, inherited indentation fix into the same edit. + tokens = lambda lines: [b"".join(line.split()) for line in lines] + for tag, start, end, target_start, target_end in difflib.SequenceMatcher( + None, tokens(before), tokens(after), autojunk=False).get_opcodes(): + if tag == "equal": + result.extend(after[target_start + index - start] if index in dirty else before[index] + for index in range(start, end)) + continue + touches_change = any(index in dirty for index in range(start, end)) + touches_change |= any(start <= boundary < end or start == end == boundary + for boundary in boundaries) + if start == end: + touches_change |= start in dirty or start - 1 in dirty + result.extend(after[target_start:target_end] if touches_change else before[start:end]) + return b"".join(result) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", help="Fail instead of modifying files") + parser.add_argument("--verbose", action="store_true", help="Show detailed Roslyn/MSBuild diagnostics") + selection = parser.add_mutually_exclusive_group() + selection.add_argument("--base", help="Include C# changes since this Git ref") + selection.add_argument("--all", action="store_true", help="Include all owned C# sources") + parser.add_argument("paths", nargs="*", help="Format whole files at these repo-relative paths") + args = parser.parse_args() + if args.paths and (args.base or args.all): + parser.error("Explicit paths cannot be combined with --base or --all") + + if args.all or args.paths: + files = git_files("ls-files", "--cached", "--others", "--exclude-standard") + else: + files = git_files("diff", "--name-only", "--diff-filter=ACMR", args.base or "HEAD") + files |= git_files("ls-files", "--others", "--exclude-standard") + files = sorted(name for name in files + if name.startswith(OWNED) and name.endswith(".cs") and (ROOT / name).is_file() + and (ROOT / name).resolve().is_relative_to(ROOT)) + if args.paths: + requested = [(ROOT / path).resolve() for path in args.paths] + if any(not path.is_relative_to(ROOT) for path in requested): + parser.error("Paths must stay inside the repository") + files = [name for name in files if any( + (ROOT / name).resolve() == path or path in (ROOT / name).resolve().parents + for path in requested)] + if not files: + print("No owned C# changes to format.") + return 0 + + originals = {name: (ROOT / name).read_bytes() for name in files} + baselines = {} + if not args.all and not args.paths: + for name in files: + result = subprocess.run(["git", "-C", str(ROOT), "show", f"{args.base or 'HEAD'}:{name}"], + capture_output=True) + baselines[name] = result.stdout if result.returncode == 0 else None + + # A tiny format-only project avoids Unity-generated csproj files and needs + # no Unity install, game data or runtime third-party assemblies. Parsing + # both variants also checks whitespace inside #if PAL3 / #if PAL3A blocks. + workspace = ROOT.parent / "workspace" + temp_root = workspace / "Tooling" / "CodeStyle" if workspace.is_dir() else None + if temp_root is not None: + temp_root.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="pal3-format-", dir=temp_root) as temporary: + # Format copies so --check is read-only and failures cannot leave partial edits. + source_root = Path(temporary) / "sources" + source_root.mkdir() + shutil.copy2(ROOT / ".editorconfig", source_root / ".editorconfig") + for name, source in originals.items(): + path = source_root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(source) + project = ET.Element("Project", Sdk="Microsoft.NET.Sdk") + properties = ET.SubElement(project, "PropertyGroup") + for name, value in { + "TargetFramework": "net10.0", + "EnableDefaultCompileItems": "false", + "GenerateAssemblyInfo": "false", + "NuGetAudit": "false", + "DefineConstants": "$(PAL3_FORMAT_VARIANT);UNITY_EDITOR", + }.items(): + ET.SubElement(properties, name).text = value + items = ET.SubElement(project, "ItemGroup") + ET.SubElement(items, "PackageReference", Include="StyleCop.Analyzers", Version="1.3.180", PrivateAssets="all") + # The SDK discovers .editorconfig from each source path automatically. + # Adding it explicitly would register it twice in the Roslyn workspace. + for name in files: + ET.SubElement(items, "Compile", Include=str(source_root / name)) + project_path = Path(temporary) / "Formatting.csproj" + ET.ElementTree(project).write(project_path, encoding="utf-8", xml_declaration=True) + print(f"{'Checking' if args.check else 'Formatting'} {len(files)} C# files (PAL3 and PAL3A).", flush=True) + subprocess.run(["dotnet", "restore", str(project_path), "--verbosity", "quiet"], cwd=ROOT, check=True) + failed = False + for variant in ("PAL3", "PAL3A"): + for subcommand, extra in (("analyzers", ["--diagnostics", *LAYOUT_RULES]), ("whitespace", [])): + verbosity = "diagnostic" if args.verbose else "minimal" + command = ["dotnet", "format", subcommand, str(project_path), "--no-restore", "--verbosity", verbosity, *extra] + result = subprocess.run(command, cwd=ROOT, env={**os.environ, "PAL3_FORMAT_VARIANT": variant}) + failed |= result.returncode != 0 + if failed: + return 1 + changed = False + for name, original in originals.items(): + formatted = (source_root / name).read_bytes() + selected = select_formatting(baselines.get(name), original, formatted) + if selected == original: + continue + changed = True + if args.check: + print(f"Formatting required: {name}") + else: + (ROOT / name).write_bytes(selected) + return 1 if args.check and changed else 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, subprocess.CalledProcessError) as error: + raise SystemExit(str(error)) from error diff --git a/tools/tests/test-format-csharp.py b/tools/tests/test-format-csharp.py new file mode 100644 index 000000000..499c348d8 --- /dev/null +++ b/tools/tests/test-format-csharp.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +# ------------------------------------------------------------------------ +# Copyright (c) 2026, Jiaqi (0x7c13) Liu. All rights reserved. +# See LICENSE file in the project root for license information. +# ------------------------------------------------------------------------ + +"""Exercise layout fixes, change boundaries and read-only checks with Roslyn.""" + +import os +from pathlib import Path +import shutil +import runpy +import subprocess +import sys +import tempfile + + +ROOT = Path(__file__).resolve().parents[2] + + +def run(command, cwd, success=True): + result = subprocess.run(command, cwd=cwd, capture_output=True, text=True, timeout=180, + env={**os.environ, "TMPDIR": str(cwd)}) + if (result.returncode == 0) != success: + raise AssertionError(f"Unexpected exit {result.returncode}: {command}\n{result.stdout}\n{result.stderr}") + return result.stdout + result.stderr + + +def main(): + select = runpy.run_path(str(ROOT / "tools/format-csharp.py"))["select_formatting"] + legacy = b"int A;\r\n\r\nint B;\r\n" + changed = legacy.replace(b"int B", b"int B") + formatted = b"int A;\r\n\r\nint B;\r\n" + assert select(legacy, changed, formatted) == legacy + assert select(legacy, legacy, formatted) == legacy + assert select(None, changed, formatted) == formatted + assert select(legacy.replace(b"\r\n", b"\n"), changed.replace(b"\r\n", b"\n"), formatted) == legacy.replace(b"\r\n", b"\n") + assert select(legacy, legacy[:-2], formatted) == legacy, "Newly removed final newline was missed" + assert select(legacy, legacy.replace(b";\r\n\r\n", b";\r\n"), formatted) == legacy + workspace = ROOT.parent / "workspace" + temporary_root = workspace / "Tooling" / "CodeStyle" if workspace.is_dir() else None + if temporary_root is not None: + temporary_root.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="pal3-format-test-", dir=temporary_root) as directory: + fixture = Path(directory) + (fixture / "tools").mkdir() + for name in (".editorconfig", "global.json", "tools/format-csharp.py"): + shutil.copy2(ROOT / name, fixture / name) + source = fixture / "Assets/Scripts/Effect layout.cs" + source.parent.mkdir(parents=True) + good = ("internal sealed class Layout\n{\n#if PAL3\n" + " public int AOne() => 1;\n\n public int ATwo() => 2;\n" + "#elif PAL3A\n public int BOne() => 1;\n\n public int BTwo() => 2;\n#endif\n}\n") + source.write_bytes(good.replace("\n", "\r\n").encode()) + plugin = fixture / "Assets/Plugins/Keep.cs" + plugin.parent.mkdir() + plugin.write_bytes(b"class Plugin{void One(){}void Two(){}}") + plugin_before = plugin.read_bytes() + run(["git", "init", "--quiet"], fixture) + run(["git", "add", "."], fixture) + run(["git", "-c", "user.name=Format fixture", "-c", "user.email=fixture@localhost", + "-c", "commit.gpgsign=false", "commit", "--quiet", "-m", "Fixture baseline"], fixture) + base = run(["git", "rev-parse", "HEAD"], fixture).strip() + command = [sys.executable, "tools/format-csharp.py"] + source.write_bytes(good.replace(";\n\n", ";\n").replace("\n", "\r\n").encode()) + before = source.read_bytes() + output = run([*command, "--check"], fixture, success=False) + assert "Formatting required:" in output, output + assert source.read_bytes() == before, "Check mode modified the source" + run(command, fixture) + fixed = source.read_bytes() + assert b"AOne() => 1;\r\n\r\n" in fixed + assert b"BOne() => 1;\r\n\r\n" in fixed + run([*command, "--check", "--all"], fixture) + assert source.read_bytes() == fixed, "Formatting is not stable" + # A violation only in PAL3A must fail the same --base mode as PR CI. + source.write_bytes(fixed.replace(b"BOne() => 1;\r\n\r\n", b"BOne() => 1;\r\n")) + output = run([*command, "--check", "--base", base], fixture, success=False) + assert "Formatting required:" in output, output + run(command, fixture) + # Keep inherited formatting outside the edit, even in the same file. + legacy = fixture / "Assets/Scripts/Legacy.cs" + legacy.write_bytes(b"class Legacy\r\n{\r\n int Untouched;\r\n\r\n int Changed;\r\n}\r\n") + run(["git", "add", "."], fixture) + run(["git", "-c", "user.name=Format fixture", "-c", "user.email=fixture@localhost", + "-c", "commit.gpgsign=false", "commit", "--quiet", "-m", "Legacy baseline"], fixture) + legacy.write_bytes(legacy.read_bytes().replace(b"int Changed", b"int Changed")) + before = legacy.read_bytes() + run([*command, "--check"], fixture, success=False) + assert legacy.read_bytes() == before, "Changed-line check modified the source" + run(command, fixture) + assert b"int Untouched;" in legacy.read_bytes(), "Inherited formatting was changed" + assert b"int Changed;" in legacy.read_bytes(), "Modified line was not formatted" + run([*command, "--check"], fixture) + # Explicit paths still provide an intentional whole-file format/audit. + run([*command, "--check", str(legacy.relative_to(fixture))], fixture, success=False) + assert plugin.read_bytes() == plugin_before, "Third-party source was modified" + print("Passed: both variants fix member gaps, checks are read-only, --base checks PAL3A, inherited formatting and plugins stay untouched, explicit paths audit whole files.") + + +if __name__ == "__main__": + main()