diff options
| author | tylermurphy534 <tylermurphy534@gmail.com> | 2022-11-06 15:12:42 -0500 |
|---|---|---|
| committer | tylermurphy534 <tylermurphy534@gmail.com> | 2022-11-06 15:12:42 -0500 |
| commit | eb84bb298d2b95aec7b2ae12cbf25ac64f25379a (patch) | |
| tree | efd616a157df06ab661c6d56651853431ac6b08b /VRCSDK3AvatarsLegacy/Assets/Resources/Lyuma/Av3Emulator/Editor | |
| download | unityprojects-eb84bb298d2b95aec7b2ae12cbf25ac64f25379a.tar.gz unityprojects-eb84bb298d2b95aec7b2ae12cbf25ac64f25379a.tar.bz2 unityprojects-eb84bb298d2b95aec7b2ae12cbf25ac64f25379a.zip | |
move to self host
Diffstat (limited to 'VRCSDK3AvatarsLegacy/Assets/Resources/Lyuma/Av3Emulator/Editor')
4 files changed, 512 insertions, 0 deletions
diff --git a/VRCSDK3AvatarsLegacy/Assets/Resources/Lyuma/Av3Emulator/Editor/LyumaAv3EditorSupport.cs b/VRCSDK3AvatarsLegacy/Assets/Resources/Lyuma/Av3Emulator/Editor/LyumaAv3EditorSupport.cs new file mode 100644 index 00000000..a866ca68 --- /dev/null +++ b/VRCSDK3AvatarsLegacy/Assets/Resources/Lyuma/Av3Emulator/Editor/LyumaAv3EditorSupport.cs @@ -0,0 +1,144 @@ +/* Copyright (c) 2020 Lyuma <xn.lyuma@gmail.com> + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. */ +using System.Collections; +using System.Collections.Generic; +using UnityEngine; +using UnityEditor; +using UnityEngine.Animations; +using UnityEditor.Animations; +using UnityEditor.Playables; +using UnityEngine.Playables; +using VRC.SDK3.Avatars.Components; + +[InitializeOnLoadAttribute] +public static class LyumaAv3EditorSupport +{ + static Dictionary<VRCAvatarDescriptor.AnimLayerType, string> animLayerToDefaultFile = new Dictionary<VRCAvatarDescriptor.AnimLayerType, string> { + {VRCAvatarDescriptor.AnimLayerType.TPose, "vrc_AvatarV3UtilityTPose"}, + {VRCAvatarDescriptor.AnimLayerType.IKPose, "vrc_AvatarV3UtilityIKPose"}, + {VRCAvatarDescriptor.AnimLayerType.Base, "vrc_AvatarV3LocomotionLayer"}, + {VRCAvatarDescriptor.AnimLayerType.Sitting, "vrc_AvatarV3SittingLayer"}, + {VRCAvatarDescriptor.AnimLayerType.Additive, "vrc_AvatarV3IdleLayer"}, + {VRCAvatarDescriptor.AnimLayerType.FX, "vrc_AvatarV3FaceLayer"}, + {VRCAvatarDescriptor.AnimLayerType.Action, "vrc_AvatarV3ActionLayer"}, + {VRCAvatarDescriptor.AnimLayerType.Gesture, "vrc_AvatarV3HandsLayer"}, + }; + static Dictionary<VRCAvatarDescriptor.AnimLayerType, string> animLayerToDefaultAvaMaskFile = new Dictionary<VRCAvatarDescriptor.AnimLayerType, string> + { + {VRCAvatarDescriptor.AnimLayerType.TPose, "vrc_MusclesOnly"}, + {VRCAvatarDescriptor.AnimLayerType.IKPose, "vrc_MusclesOnly"}, + {VRCAvatarDescriptor.AnimLayerType.Base, null},//"LyumaFullMask"}, + {VRCAvatarDescriptor.AnimLayerType.Sitting, null},//"LyumaFullMask"}, + {VRCAvatarDescriptor.AnimLayerType.Additive, null},//"LyumaFullMask"}, + {VRCAvatarDescriptor.AnimLayerType.FX, "LyumaEmptyMask"}, // TODO + {VRCAvatarDescriptor.AnimLayerType.Action, null},//"vrc_MusclesOnly"}, + {VRCAvatarDescriptor.AnimLayerType.Gesture, "vrc_HandsOnly"}, + }; + + static void InitDefaults() { + foreach (var kv in animLayerToDefaultFile) { + if (kv.Value == null) { + LyumaAv3Runtime.animLayerToDefaultController[kv.Key] = null; + } else + { + AnimatorController ac = AssetDatabase.LoadAssetAtPath<AnimatorController>("Assets/VRCSDK/Examples3/Animation/Controllers/" + kv.Value + ".controller"); + if (ac == null) + { + Debug.LogWarning("Failed to resolve animator controller " + kv.Value + " for " + kv.Key); + ac = null; + } + LyumaAv3Runtime.animLayerToDefaultController[kv.Key] = ac; + } + } + foreach (var kv in animLayerToDefaultAvaMaskFile) { + if (kv.Value == null) { + LyumaAv3Runtime.animLayerToDefaultAvaMask[kv.Key] = null; + } else + { + AvatarMask mask = null; + foreach (var guid in AssetDatabase.FindAssets(kv.Value)) + { + string path = AssetDatabase.GUIDToAssetPath(guid); + mask = AssetDatabase.LoadAssetAtPath<AvatarMask>(path); + } + if (mask == null) + { + Debug.LogWarning("Failed to resolve avatar mask " + kv.Value + " for " + kv.Key); + mask = new AvatarMask(); + } + LyumaAv3Runtime.animLayerToDefaultAvaMask[kv.Key] = mask; + } + } + foreach (string guid in AssetDatabase.FindAssets("EmptyController")) { + LyumaAv3Emulator.EmptyController = AssetDatabase.LoadAssetAtPath<RuntimeAnimatorController>(AssetDatabase.GUIDToAssetPath(guid)); + } + + LyumaAv3Runtime.updateSelectionDelegate = (go) => { + if (go == null && LyumaAv3Emulator.emulatorInstance != null) { + Debug.Log("Resetting selected object: " + LyumaAv3Emulator.emulatorInstance); + go = LyumaAv3Emulator.emulatorInstance.gameObject; + } + Debug.Log("Setting selected object: " + go); + Selection.SetActiveObjectWithContext(go, go); + // Highlighter.Highlight("Inspector", "Animator To Debug"); + }; + + LyumaAv3Runtime.addRuntimeDelegate = (runtime) => { + GameObject go = runtime.gameObject; + try { + if (PrefabUtility.IsPartOfAnyPrefab(go)) { + PrefabUtility.UnpackPrefabInstance(go, PrefabUnpackMode.Completely, InteractionMode.AutomatedAction); + } + } catch (System.Exception) {} + int moveUpCalls = go.GetComponents<Component>().Length - 2; + if (!PrefabUtility.IsPartOfAnyPrefab(go.GetComponents<Component>()[1])) { + for (int i = 0; i < moveUpCalls; i++) { + UnityEditorInternal.ComponentUtility.MoveComponentUp(runtime); + } + } + }; + LyumaAv3Menu.addRuntimeDelegate = (menu) => { + GameObject go = menu.gameObject; + try { + if (PrefabUtility.IsPartOfAnyPrefab(go)) { + PrefabUtility.UnpackPrefabInstance(go, PrefabUnpackMode.Completely, InteractionMode.AutomatedAction); + } + } catch (System.Exception) {} + int moveUpCalls = go.GetComponents<Component>().Length - 2; + if (!PrefabUtility.IsPartOfAnyPrefab(go.GetComponents<Component>()[1])) { + for (int i = 0; i < moveUpCalls; i++) { + UnityEditorInternal.ComponentUtility.MoveComponentUp(menu); + } + } + }; + } + + // register an event handler when the class is initialized + static LyumaAv3EditorSupport() + { + InitDefaults(); + } + + [MenuItem("Tools/Enable Avatars 3.0 Emulator")] + public static void EnableAv3Testing() { + GameObject go = new GameObject("Avatars 3.0 Emulator Control"); + go.AddComponent<LyumaAv3Emulator>(); + } +} diff --git a/VRCSDK3AvatarsLegacy/Assets/Resources/Lyuma/Av3Emulator/Editor/LyumaAv3EditorSupport.cs.meta b/VRCSDK3AvatarsLegacy/Assets/Resources/Lyuma/Av3Emulator/Editor/LyumaAv3EditorSupport.cs.meta new file mode 100644 index 00000000..79bb5d5f --- /dev/null +++ b/VRCSDK3AvatarsLegacy/Assets/Resources/Lyuma/Av3Emulator/Editor/LyumaAv3EditorSupport.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 77ddade7c6475a242a8e34b2b7554adc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VRCSDK3AvatarsLegacy/Assets/Resources/Lyuma/Av3Emulator/Editor/LyumaAv3MenuEditor.cs b/VRCSDK3AvatarsLegacy/Assets/Resources/Lyuma/Av3Emulator/Editor/LyumaAv3MenuEditor.cs new file mode 100644 index 00000000..293f4ef1 --- /dev/null +++ b/VRCSDK3AvatarsLegacy/Assets/Resources/Lyuma/Av3Emulator/Editor/LyumaAv3MenuEditor.cs @@ -0,0 +1,354 @@ +/* Copyright (c) 2020 Lyuma <xn.lyuma@gmail.com> + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. */ + +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEngine; +using VRC.SDK3.Avatars.ScriptableObjects; + +[CustomEditor(typeof(LyumaAv3Menu))] +public class LyumaAv3MenuEditor : Editor +{ + private readonly Dictionary<Texture2D, Texture2D> _resizedIcons = new Dictionary<Texture2D, Texture2D>(); + private VRCExpressionsMenu _currentMenu; + + public override void OnInspectorGUI() + { + var menu = (LyumaAv3Menu)target; + if (menu.Runtime == null) return; + if (menu.RootMenu == null) + { + menu.RootMenu = (VRCExpressionsMenu)EditorGUILayout.ObjectField(new GUIContent("Expressions Menu"), null, typeof(VRCExpressionsMenu), false); + return; + } + + var isInRootMenu = menu.MenuStack.Count == 0; + GUILayout.BeginHorizontal(); + if (GUILayout.Button(menu.IsMenuOpen ? "Close menu" : "Open menu")) + { + menu.ToggleMenu(); + } + + if (menu.gameObject.GetComponents<LyumaAv3Menu>().Length == 1) + { + if (GUILayout.Button("+", GUILayout.Width(20))) + { + OpenMenuForTwoHandedSupport(menu); + } + } + + GUILayout.EndHorizontal(); + + GUILayout.Label( + (isInRootMenu ? "Expressions" : LabelizeMenu()) + + (menu.IsMenuOpen ? "" : " [Menu is closed]"), + EditorStyles.boldLabel); + + if (!menu.IsMenuOpen) { + return; + } + + _currentMenu = menu.MenuStack.Count == 0 ? menu.RootMenu : menu.MenuStack.Last().ExpressionsMenu; + + EditorGUI.BeginDisabledGroup(true); + EditorGUILayout.ObjectField(_currentMenu, typeof(VRCExpressionsMenu), false); + EditorGUI.EndDisabledGroup(); + + EditorGUI.BeginDisabledGroup(isInRootMenu || menu.HasActiveControl()); + if (GUILayout.Button("Back")) + { + menu.UserBack(); + } + EditorGUI.EndDisabledGroup(); + for (var controlIndex = 0; controlIndex < _currentMenu.controls.Count; controlIndex++) + { + var control = _currentMenu.controls[controlIndex]; + switch (control.type) + { + case VRCExpressionsMenu.Control.ControlType.Button: + FromToggle(control, "Button"); + break; + case VRCExpressionsMenu.Control.ControlType.Toggle: + FromToggle(control, "Toggle"); + break; + case VRCExpressionsMenu.Control.ControlType.SubMenu: + FromSubMenu(control); + break; + case VRCExpressionsMenu.Control.ControlType.TwoAxisPuppet: + FromTwoAxis(control, controlIndex); + break; + case VRCExpressionsMenu.Control.ControlType.FourAxisPuppet: + FromFourAxis(control, controlIndex); + break; + case VRCExpressionsMenu.Control.ControlType.RadialPuppet: + FromRadial(control, controlIndex); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + if (_currentMenu.controls.Count == 0) + { + EditorGUILayout.LabelField("(This menu has no controls)"); + } + } + + private static void OpenMenuForTwoHandedSupport(LyumaAv3Menu menu) + { + var mainMenu = menu.Runtime.gameObject.AddComponent<LyumaAv3Menu>(); + mainMenu.Runtime = menu.Runtime; + mainMenu.RootMenu = menu.RootMenu; + } + + private string LabelizeMenu() + { + var menu = (LyumaAv3Menu)target; + + var lastMenu = menu.MenuStack.Last(); + if (lastMenu.MandatedParam == null) + { + return lastMenu.ExpressionsMenu.name; + } + + return lastMenu.ExpressionsMenu.name + " (" + lastMenu.MandatedParam.name + " = " + lastMenu.MandatedParam.value + ")"; + } + + private void FromToggle(VRCExpressionsMenu.Control control, string labelType) + { + var menu = (LyumaAv3Menu)target; + + var parameterName = control.parameter.name; + var controlValue = control.value; + + var isActive = menu.IsVisualActive(parameterName, controlValue); + + EditorGUILayout.BeginHorizontal(); + EditorGUI.BeginDisabledGroup(menu.HasActiveControl()); + if (GreenBackground(isActive, () => ParameterizedButton(control, parameterName, controlValue))) + { + menu.UserToggle(parameterName, controlValue); + } + EditorGUI.EndDisabledGroup(); + LabelType(labelType); + EditorGUILayout.EndHorizontal(); + } + + private void FromSubMenu(VRCExpressionsMenu.Control control) + { + var menu = (LyumaAv3Menu)target; + + var parameterName = control.parameter.name; + var wantedValue = control.value; + + EditorGUILayout.BeginHorizontal(); + EditorGUI.BeginDisabledGroup(menu.HasActiveControl()); + if (ParameterizedButton(control, parameterName, wantedValue)) + { + if (IsValidParameterName(parameterName)) + { + menu.UserSubMenu(control.subMenu, parameterName, wantedValue); + } + else + { + menu.UserSubMenu(control.subMenu); + } + } + EditorGUI.EndDisabledGroup(); + LabelType("SubMenu"); + EditorGUILayout.EndHorizontal(); + } + + private void FromRadial(VRCExpressionsMenu.Control control, int controlIndex) + { + var menu = (LyumaAv3Menu)target; + + SubControl(control, controlIndex, menu, "Radial"); + + if (menu.IsActiveControl(controlIndex)) + { + if (control.subParameters.Length > 0) + { + SliderFloat(menu, control.subParameters[0], "Rotation", 0f, 1f); + } + } + } + + private void FromTwoAxis(VRCExpressionsMenu.Control control, int controlIndex) + { + var menu = (LyumaAv3Menu)target; + + SubControl(control, controlIndex, menu, "TwoAxis"); + + if (menu.IsActiveControl(controlIndex)) + { + var sanitySubParamLength = control.subParameters.Length; + if (sanitySubParamLength > 0) SliderFloat(menu, control.subParameters[0], "Horizontal", -1f, 1f); + if (sanitySubParamLength > 1) SliderFloat(menu, control.subParameters[1], "Vertical", -1f, 1f); + + var oldColor = Color.HSVToRGB( + 0, + sanitySubParamLength > 0 ? menu.FindFloat(control.subParameters[0].name) * 0.5f + 0.5f : 0, + sanitySubParamLength > 1 ? menu.FindFloat(control.subParameters[1].name) * 0.5f + 0.5f : 0); + var newColor = EditorGUILayout.ColorField(oldColor); + if (oldColor.r != newColor.r || oldColor.g != newColor.g || oldColor.b != newColor.b) + { + Color.RGBToHSV(newColor, out _, out var s, out var v); + if (sanitySubParamLength > 0) menu.UserFloat(control.subParameters[0].name, s * 2 - 1); + if (sanitySubParamLength > 1) menu.UserFloat(control.subParameters[1].name, v * 2 - 1); + } + } + } + + private void FromFourAxis(VRCExpressionsMenu.Control control, int controlIndex) + { + var menu = (LyumaAv3Menu)target; + + SubControl(control, controlIndex, menu, "FourAxis"); + + if (menu.IsActiveControl(controlIndex)) + { + var sanitySubParamLength = control.subParameters.Length; + if (sanitySubParamLength > 0) SliderFloat(menu, control.subParameters[0], "Up", 0f, 1f); + if (sanitySubParamLength > 1) SliderFloat(menu, control.subParameters[1], "Right", 0f, 1f); + if (sanitySubParamLength > 2) SliderFloat(menu, control.subParameters[2], "Down", 0f, 1f); + if (sanitySubParamLength > 3) SliderFloat(menu, control.subParameters[3], "Left", 0f, 1f); + + var oldColor = Color.HSVToRGB( + 0, + (sanitySubParamLength > 0 ? menu.FindFloat(control.subParameters[0].name) : 0) * 0.5f + 0.5f + -(sanitySubParamLength > 2 ? menu.FindFloat(control.subParameters[2].name) : 0) * 0.5f + 0.5f, + (sanitySubParamLength > 1 ? menu.FindFloat(control.subParameters[1].name) : 0) * 0.5f + 0.5f + -(sanitySubParamLength > 3 ? menu.FindFloat(control.subParameters[3].name) : 0) * 0.5f + 0.5f); + var newColor = EditorGUILayout.ColorField(oldColor); + if (oldColor.r != newColor.r || oldColor.g != newColor.g || oldColor.b != newColor.b) + { + Color.RGBToHSV(newColor, out _, out var s, out var v); + if (sanitySubParamLength > 0) menu.UserFloat(control.subParameters[0].name, Mathf.Clamp(v * 2 - 1, 0f, 1f)); + if (sanitySubParamLength > 1) menu.UserFloat(control.subParameters[1].name, Mathf.Clamp(s * 2 - 1, 0f, 1f)); + if (sanitySubParamLength > 2) menu.UserFloat(control.subParameters[2].name, -Mathf.Clamp(v * 2 - 1, -1f, 0f)); + if (sanitySubParamLength > 3) menu.UserFloat(control.subParameters[3].name, -Mathf.Clamp(s * 2 - 1, -1f, 0f)); + } + } + } + + private void SubControl(VRCExpressionsMenu.Control control, int controlIndex, LyumaAv3Menu menu, string labelType) + { + var parameterName = control.parameter.name; + var intValue = (int) control.value; + + var isActive = menu.IsVisualActive(parameterName, intValue); + + EditorGUILayout.BeginHorizontal(); + EditorGUI.BeginDisabledGroup(menu.HasActiveControl() && !menu.IsActiveControl(controlIndex)); + if (GreenBackground(isActive || menu.IsActiveControl(controlIndex), () => ParameterizedButton(control, parameterName, intValue))) + { + if (!menu.IsActiveControl(controlIndex)) + { + if (IsValidParameterName(parameterName)) + { + menu.UserControlEnter(controlIndex, parameterName, intValue); + } + else + { + menu.UserControlEnter(controlIndex); + } + } + else + { + menu.UserControlExit(); + } + } + + EditorGUI.EndDisabledGroup(); + LabelType(labelType); + EditorGUILayout.EndHorizontal(); + } + + private static void SliderFloat(LyumaAv3Menu menu, VRCExpressionsMenu.Control.Parameter subParam, string intent, float left, float right) + { + if (subParam == null || subParam.name == "") + { + EditorGUI.BeginDisabledGroup(true); + EditorGUILayout.Slider(intent, 0, left, right); + EditorGUI.EndDisabledGroup(); + return; + } + + menu.UserFloat(subParam.name, EditorGUILayout.Slider(intent + " (" + subParam.name + ")", menu.FindFloat(subParam.name), left, right)); + } + + private bool ParameterizedButton(VRCExpressionsMenu.Control control, string parameterName, float wantedValue) + { + var hasParameter = IsValidParameterName(parameterName); + return GUILayout.Button(new GUIContent(control.name + (hasParameter ? " (" + parameterName + " = " + wantedValue + ")" : ""), ResizedIcon(control.icon))); + } + + private Texture2D ResizedIcon(Texture2D originalIcon) + { + if (_resizedIcons.ContainsKey(originalIcon)) + { + return _resizedIcons[originalIcon]; + } + + var resizedIcon = GenerateResizedIcon(originalIcon, 32); + _resizedIcons[originalIcon] = resizedIcon; + return resizedIcon; + } + + private static Texture2D GenerateResizedIcon(Texture2D originalIcon, int width) + { + var render = new RenderTexture(width, width, 24); + RenderTexture.active = render; + Graphics.Blit(originalIcon, render); + + var resizedIcon = new Texture2D(width, width); + resizedIcon.ReadPixels(new Rect(0, 0, width, width), 0, 0); + resizedIcon.Apply(); + + return resizedIcon; + } + + private static T GreenBackground<T>(bool isActive, Func<T> inside) + { + var col = GUI.color; + try + { + if (isActive) GUI.color = Color.green; + return inside(); + } + finally + { + GUI.color = col; + } + } + + private static void LabelType(string toggle) + { + EditorGUILayout.LabelField(toggle, GUILayout.Width(70), GUILayout.ExpandHeight(true)); + } + + private static bool IsValidParameterName(string parameterName) + { + return !string.IsNullOrEmpty(parameterName); + } +} diff --git a/VRCSDK3AvatarsLegacy/Assets/Resources/Lyuma/Av3Emulator/Editor/LyumaAv3MenuEditor.cs.meta b/VRCSDK3AvatarsLegacy/Assets/Resources/Lyuma/Av3Emulator/Editor/LyumaAv3MenuEditor.cs.meta new file mode 100644 index 00000000..f613af7d --- /dev/null +++ b/VRCSDK3AvatarsLegacy/Assets/Resources/Lyuma/Av3Emulator/Editor/LyumaAv3MenuEditor.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 187ea10b8ccd4441a6399698c23122e3 +timeCreated: 1604152513
\ No newline at end of file |