Browse Source

Merge remote-tracking branch 'origin/dev'

habeebweeb 3 years ago
parent
commit
8133dfec50
149 changed files with 19610 additions and 2 deletions
  1. 2 2
      .gitignore
  2. 37 0
      src/MeidoPhotoStudio.Plugin/Config/MeidoPhotoStudio/Database/bg_ignore_list.json
  3. 58 0
      src/MeidoPhotoStudio.Plugin/Config/MeidoPhotoStudio/Database/extra_dogu.json
  4. 114 0
      src/MeidoPhotoStudio.Plugin/Config/MeidoPhotoStudio/Database/face_slider_limits.json
  5. 529 0
      src/MeidoPhotoStudio.Plugin/Config/MeidoPhotoStudio/Database/mm_pose_list.json
  6. 212 0
      src/MeidoPhotoStudio.Plugin/Config/MeidoPhotoStudio/Translations/en/translation.bg.json
  7. 121 0
      src/MeidoPhotoStudio.Plugin/Config/MeidoPhotoStudio/Translations/en/translation.face.json
  8. 1186 0
      src/MeidoPhotoStudio.Plugin/Config/MeidoPhotoStudio/Translations/en/translation.props.json
  9. 443 0
      src/MeidoPhotoStudio.Plugin/Config/MeidoPhotoStudio/Translations/en/translation.ui.json
  10. 15 0
      src/MeidoPhotoStudio.Plugin/Configuration.cs
  11. 1053 0
      src/MeidoPhotoStudio.Plugin/Constants.cs
  12. 146 0
      src/MeidoPhotoStudio.Plugin/DragPoint/CustomGizmo.cs
  13. 258 0
      src/MeidoPhotoStudio.Plugin/DragPoint/DragPoint.cs
  14. 214 0
      src/MeidoPhotoStudio.Plugin/DragPoint/DragPointGeneral.cs
  15. 86 0
      src/MeidoPhotoStudio.Plugin/DragPoint/DragPointGravity.cs
  16. 267 0
      src/MeidoPhotoStudio.Plugin/DragPoint/DragPointLight.cs
  17. 65 0
      src/MeidoPhotoStudio.Plugin/DragPoint/DragPointMeido.cs
  18. 47 0
      src/MeidoPhotoStudio.Plugin/DragPoint/DragPointOther.cs
  19. 113 0
      src/MeidoPhotoStudio.Plugin/DragPoint/DragPointProp.cs
  20. 12 0
      src/MeidoPhotoStudio.Plugin/GUI/Controls/BaseControl.cs
  21. 21 0
      src/MeidoPhotoStudio.Plugin/GUI/Controls/Button.cs
  22. 47 0
      src/MeidoPhotoStudio.Plugin/GUI/Controls/ComboBox.cs
  23. 359 0
      src/MeidoPhotoStudio.Plugin/GUI/Controls/DropDown.cs
  24. 56 0
      src/MeidoPhotoStudio.Plugin/GUI/Controls/KeyRebindButton.cs
  25. 39 0
      src/MeidoPhotoStudio.Plugin/GUI/Controls/Modal.cs
  26. 103 0
      src/MeidoPhotoStudio.Plugin/GUI/Controls/SelectionGrid.cs
  27. 168 0
      src/MeidoPhotoStudio.Plugin/GUI/Controls/Slider.cs
  28. 17 0
      src/MeidoPhotoStudio.Plugin/GUI/Controls/TextArea.cs
  29. 24 0
      src/MeidoPhotoStudio.Plugin/GUI/Controls/TextField.cs
  30. 44 0
      src/MeidoPhotoStudio.Plugin/GUI/Controls/Toggle.cs
  31. 188 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindow2Panes/AttachPropPane.cs
  32. 196 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindow2Panes/ModPropsPane.cs
  33. 93 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindow2Panes/MyRoomPropsPane.cs
  34. 151 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindow2Panes/PropManagerPane.cs
  35. 168 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindow2Panes/PropsPane.cs
  36. 82 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindowPanes/BackgroundSelectorPane.cs
  37. 86 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindowPanes/CameraPane.cs
  38. 74 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindowPanes/DragPointPane.cs
  39. 104 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindowPanes/EffectsPanes/BloomPane.cs
  40. 87 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindowPanes/EffectsPanes/DepthOfFieldPane.cs
  41. 74 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindowPanes/EffectsPanes/EffectPane.cs
  42. 51 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindowPanes/EffectsPanes/EffectsPane.cs
  43. 115 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindowPanes/EffectsPanes/FogPane.cs
  44. 64 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindowPanes/EffectsPanes/OtherEffectsPane.cs
  45. 72 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindowPanes/EffectsPanes/VignettePane.cs
  46. 295 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindowPanes/LightsPane.cs
  47. 26 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/BasePane.cs
  48. 79 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/CallWindowPanes/MaidSelectorPane.cs
  49. 159 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/FaceWindowPanes/MaidFaceBlendPane.cs
  50. 214 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/FaceWindowPanes/MaidFaceSliderPane.cs
  51. 63 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/FaceWindowPanes/SaveFacePane.cs
  52. 59 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/MainWindowPanes/BG2WindowPane.cs
  53. 66 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/MainWindowPanes/BGWindowPane.cs
  54. 13 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/MainWindowPanes/BaseMainWindowPane.cs
  55. 47 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/MainWindowPanes/CallWindowPane.cs
  56. 64 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/MainWindowPanes/FaceWindowPane.cs
  57. 147 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/MainWindowPanes/PoseWindowPane.cs
  58. 150 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/MainWindowPanes/SettingsWindowPane.cs
  59. 119 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/OtherPanes/MaidSwitcherPane.cs
  60. 45 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/OtherPanes/TabsPane.cs
  61. 80 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/PoseWindowPanes/CopyPosePane.cs
  62. 97 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/PoseWindowPanes/GravityControlPane.cs
  63. 134 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/PoseWindowPanes/HandPresetPane.cs
  64. 289 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/PoseWindowPanes/MaidDressingPane.cs
  65. 102 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/PoseWindowPanes/MaidFreeLookPane.cs
  66. 71 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/PoseWindowPanes/MaidIKPane.cs
  67. 212 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/PoseWindowPanes/MaidPoseSelectorPane.cs
  68. 111 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/PoseWindowPanes/MpnAttachPropPane.cs
  69. 70 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/PoseWindowPanes/SaveHandPane.cs
  70. 63 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/PoseWindowPanes/SavePosePane.cs
  71. 100 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/SceneManagerPanes/SceneManagerDirectoryPane.cs
  72. 85 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/SceneManagerPanes/SceneManagerScenePane.cs
  73. 108 0
      src/MeidoPhotoStudio.Plugin/GUI/Panes/SceneManagerPanes/SceneManagerTitleBar.cs
  74. 50 0
      src/MeidoPhotoStudio.Plugin/GUI/Windows/BaseWindow.cs
  75. 23 0
      src/MeidoPhotoStudio.Plugin/GUI/Windows/BaseWindowPane.cs
  76. 148 0
      src/MeidoPhotoStudio.Plugin/GUI/Windows/MainWindow.cs
  77. 88 0
      src/MeidoPhotoStudio.Plugin/GUI/Windows/MessageWindow.cs
  78. 250 0
      src/MeidoPhotoStudio.Plugin/GUI/Windows/SceneModalWindow.cs
  79. 110 0
      src/MeidoPhotoStudio.Plugin/GUI/Windows/SceneWindow.cs
  80. 222 0
      src/MeidoPhotoStudio.Plugin/LexicographicStringComparer.cs
  81. 70 0
      src/MeidoPhotoStudio.Plugin/MPSScene.cs
  82. 145 0
      src/MeidoPhotoStudio.Plugin/MaidPlacementUtility.cs
  83. 178 0
      src/MeidoPhotoStudio.Plugin/Managers/CameraManager.cs
  84. 35 0
      src/MeidoPhotoStudio.Plugin/Managers/EffectManager.cs
  85. 143 0
      src/MeidoPhotoStudio.Plugin/Managers/EffectManagers/BloomEffectManager.cs
  86. 70 0
      src/MeidoPhotoStudio.Plugin/Managers/EffectManagers/BlurEffectManager.cs
  87. 100 0
      src/MeidoPhotoStudio.Plugin/Managers/EffectManagers/DepthOfFieldManager.cs
  88. 126 0
      src/MeidoPhotoStudio.Plugin/Managers/EffectManagers/FogEffectManager.cs
  89. 10 0
      src/MeidoPhotoStudio.Plugin/Managers/EffectManagers/IEffectManager.cs
  90. 32 0
      src/MeidoPhotoStudio.Plugin/Managers/EffectManagers/SepiaToneEffectManager.cs
  91. 87 0
      src/MeidoPhotoStudio.Plugin/Managers/EffectManagers/VignetteEffectManager.cs
  92. 144 0
      src/MeidoPhotoStudio.Plugin/Managers/EnvironmentManager.cs
  93. 9 0
      src/MeidoPhotoStudio.Plugin/Managers/IManager.cs
  94. 148 0
      src/MeidoPhotoStudio.Plugin/Managers/InputManager.cs
  95. 173 0
      src/MeidoPhotoStudio.Plugin/Managers/LightManager.cs
  96. 333 0
      src/MeidoPhotoStudio.Plugin/Managers/MeidoManager.cs
  97. 100 0
      src/MeidoPhotoStudio.Plugin/Managers/MessageWindowManager.cs
  98. 320 0
      src/MeidoPhotoStudio.Plugin/Managers/PropManager.cs
  99. 332 0
      src/MeidoPhotoStudio.Plugin/Managers/SceneManager.cs
  100. 51 0
      src/MeidoPhotoStudio.Plugin/Managers/WindowManager.cs
  101. 87 0
      src/MeidoPhotoStudio.Plugin/Meido/IK/DragPointFinger.cs
  102. 112 0
      src/MeidoPhotoStudio.Plugin/Meido/IK/DragPointHead.cs
  103. 52 0
      src/MeidoPhotoStudio.Plugin/Meido/IK/DragPointPelvis.cs
  104. 106 0
      src/MeidoPhotoStudio.Plugin/Meido/IK/DragPointSpine.cs
  105. 77 0
      src/MeidoPhotoStudio.Plugin/Meido/IK/DragPointTorso.cs
  106. 39 0
      src/MeidoPhotoStudio.Plugin/Meido/IK/IK Chain/DragPointChain.cs
  107. 111 0
      src/MeidoPhotoStudio.Plugin/Meido/IK/IK Chain/DragPointLimb.cs
  108. 57 0
      src/MeidoPhotoStudio.Plugin/Meido/IK/IK Chain/DragPointMune.cs
  109. 771 0
      src/MeidoPhotoStudio.Plugin/Meido/Meido.cs
  110. 701 0
      src/MeidoPhotoStudio.Plugin/Meido/MeidoDragPointManager.cs
  111. 47 0
      src/MeidoPhotoStudio.Plugin/MeidoPhotoStudio.Plugin.csproj
  112. 491 0
      src/MeidoPhotoStudio.Plugin/MeidoPhotoStudio.cs
  113. 62 0
      src/MeidoPhotoStudio.Plugin/MenuFileCache.cs
  114. 233 0
      src/MeidoPhotoStudio.Plugin/MenuFileUtility.cs
  115. 70 0
      src/MeidoPhotoStudio.Plugin/MenuItem.cs
  116. 565 0
      src/MeidoPhotoStudio.Plugin/ModelUtility.cs
  117. 85 0
      src/MeidoPhotoStudio.Plugin/MyGui.cs
  118. 24 0
      src/MeidoPhotoStudio.Plugin/Patchers/AllProcPropSeqStartPatcher.cs
  119. 21 0
      src/MeidoPhotoStudio.Plugin/Patchers/BgMgrPatcher.cs
  120. 10 0
      src/MeidoPhotoStudio.Plugin/Serialization/ISerializer.cs
  121. 10 0
      src/MeidoPhotoStudio.Plugin/Serialization/ISimpleSerializer.cs
  122. 41 0
      src/MeidoPhotoStudio.Plugin/Serialization/SceneMetadata.cs
  123. 42 0
      src/MeidoPhotoStudio.Plugin/Serialization/Serialization.cs
  124. 15 0
      src/MeidoPhotoStudio.Plugin/Serialization/Serializer.cs
  125. 27 0
      src/MeidoPhotoStudio.Plugin/Serialization/Serializers/AttachPointInfoSerializer.cs
  126. 29 0
      src/MeidoPhotoStudio.Plugin/Serialization/Serializers/CameraInfoSerializer.cs
  127. 41 0
      src/MeidoPhotoStudio.Plugin/Serialization/Serializers/DragPointLightSerializer.cs
  128. 34 0
      src/MeidoPhotoStudio.Plugin/Serialization/Serializers/EffectSerializers/BloomEffectSerializer.cs
  129. 28 0
      src/MeidoPhotoStudio.Plugin/Serialization/Serializers/EffectSerializers/BlurEffectSerializer.cs
  130. 36 0
      src/MeidoPhotoStudio.Plugin/Serialization/Serializers/EffectSerializers/DepthOfFieldEffectSerializer.cs
  131. 36 0
      src/MeidoPhotoStudio.Plugin/Serialization/Serializers/EffectSerializers/FogEffectSerializer.cs
  132. 24 0
      src/MeidoPhotoStudio.Plugin/Serialization/Serializers/EffectSerializers/SepiaToneEffectSerializer.cs
  133. 34 0
      src/MeidoPhotoStudio.Plugin/Serialization/Serializers/EffectSerializers/VignetteEffectSerializer.cs
  134. 33 0
      src/MeidoPhotoStudio.Plugin/Serialization/Serializers/LightPropertySerializer.cs
  135. 46 0
      src/MeidoPhotoStudio.Plugin/Serialization/Serializers/ManagerSerializers/CameraManagerSerializer.cs
  136. 43 0
      src/MeidoPhotoStudio.Plugin/Serialization/Serializers/ManagerSerializers/EffectManagerSerializer.cs
  137. 71 0
      src/MeidoPhotoStudio.Plugin/Serialization/Serializers/ManagerSerializers/EnvironmentManagerSerializer.cs
  138. 43 0
      src/MeidoPhotoStudio.Plugin/Serialization/Serializers/ManagerSerializers/LightManagerSerializer.cs
  139. 86 0
      src/MeidoPhotoStudio.Plugin/Serialization/Serializers/ManagerSerializers/MeidoManagerSerializer.cs
  140. 33 0
      src/MeidoPhotoStudio.Plugin/Serialization/Serializers/ManagerSerializers/MessageWindowManagerSerializer.cs
  141. 69 0
      src/MeidoPhotoStudio.Plugin/Serialization/Serializers/ManagerSerializers/PropManagerSerializer.cs
  142. 277 0
      src/MeidoPhotoStudio.Plugin/Serialization/Serializers/MeidoSerializer.cs
  143. 15 0
      src/MeidoPhotoStudio.Plugin/Serialization/SimpleSerializer.cs
  144. 66 0
      src/MeidoPhotoStudio.Plugin/Serialization/SimpleSerializers/DragPointPropDTOSerializer.cs
  145. 25 0
      src/MeidoPhotoStudio.Plugin/Serialization/SimpleSerializers/PoseInfoSerializer.cs
  146. 33 0
      src/MeidoPhotoStudio.Plugin/Serialization/SimpleSerializers/PropInfoSerializer.cs
  147. 55 0
      src/MeidoPhotoStudio.Plugin/Serialization/SimpleSerializers/TransformDTOSerializer.cs
  148. 144 0
      src/MeidoPhotoStudio.Plugin/Translation.cs
  149. 427 0
      src/MeidoPhotoStudio.Plugin/Utility.cs

+ 2 - 2
.gitignore

@@ -1,8 +1,8 @@
 lib/
 
 # Ignore asciidoc output
-Documentation/[Oo]ut/
-Documentation/*.html
+[Dd]ocumentation/[Oo]ut/
+[Dd]ocumentation/*.html
 
 ## Ignore Visual Studio temporary files, build results, and
 ## files generated by popular Visual Studio add-ons.

+ 37 - 0
src/MeidoPhotoStudio.Plugin/Config/MeidoPhotoStudio/Database/bg_ignore_list.json

@@ -0,0 +1,37 @@
+/*
+    This file is included as a convenience to prevent C(O)M3D2 BGs from being added to the 'Props 1' dropdown
+
+    You should consider checking your 'phot_bg_list_add.nei' file in the 'PhotoBG_NEI' folder somewhere in your 'Mod'
+    folder to see if you're missing any of these items and add them. This will have the added benefit of having these
+    BGs show up in the backgrounds dropdown and in studio mode.
+*/
+[
+    "classroom_nodesk",
+    "heroineroom_d1",
+    "heroineroom_d1_night",
+    "heroineroom_d",
+    "heroineroom_d_night",
+    "heroineroom_e1",
+    "heroineroom_e1_night",
+    "heroineroom_e",
+    "heroineroom_e_night",
+    "heroineroom_f1",
+    "heroineroom_f1_night",
+    "heroineroom_f",
+    "heroineroom_f_night",
+    "heroineroom_g1",
+    "heroineroom_g1_night",
+    "heroineroom_g",
+    "heroineroom_g_night",
+    "heroineroom_h1",
+    "heroineroom_h1_night",
+    "heroineroom_j1",
+    "heroineroom_j1_night",
+    "heroineroom_k1",
+    "heroineroom_k1_night",
+    "heroineroom_l1",
+    "heroineroom_l1_night",
+    "KaraokeRoom",
+    "train_notsurikawa",
+    "yashiki_Pillow"
+]

+ 58 - 0
src/MeidoPhotoStudio.Plugin/Config/MeidoPhotoStudio/Database/extra_dogu.json

@@ -0,0 +1,58 @@
+/*
+    This file contains extra props that I cannot find in the nei files.
+    They might be in there but I can't find them.
+
+    This file should not be modified.
+*/
+[
+    "megane001_z2_scenario_model",
+    "odogu_chuukaset_chahan_photo_ver",
+    "odogu_chuukaset_gyouza_photo_ver",
+    "odogu_chuukaset_mabo_photo_ver",
+    "odogu_chuukaset_tea_photo_ver",
+    "odogu_classroomchair_photo_ver",
+    "odogu_classroomdesk_photo_ver",
+    "odogu_handcameravv_photo_ver",
+    "odogu_kitchen_photo_ver",
+    "odogu_maidroomchair_photo_ver",
+    "odogu_oxcamera_photo_ver",
+    "odogu_pc_photo_ver",
+    "odogu_pc_keyboard_photo_ver",
+    "odogu_pc_monitor_photo_ver",
+    "odogu_pc_mouse_photo_ver",
+    "odogu_planter_lightblue",
+    "odogu_planter_red",
+    "odogu_pr_table_photo_ver",
+    "odogu_pr_table_chuuka_photo_ver",
+    "odogu_pr_table_wasyoku_photo_ver",
+    "odogu_pr_table_yousyoku_photo_ver",
+    "odogu_publictoiletbenki_photo_ver",
+    "odogu_salonscreen_photo_ver",
+    "odogu_seikaku_cool",
+    "odogu_seikaku_jyunshin",
+    "odogu_seikaku_tsundere",
+    "odogu_sentaku_kago_photo_ver",
+    "odogu_sentaku_towel_photo_ver",
+    "odogu_sentakumono",
+    "odogu_smroom2_sankakumokuba",
+    "odogu_styluspen_black",
+    "odogu_styluspen_blue",
+    "odogu_styluspen_green",
+    "odogu_styluspen_red",
+    "odogu_styluspen_white",
+    "odogu_styluspen_yellow",
+    "odogu_tableflower_photo_ver",
+    "odogu_tabletpc",
+    "odogu_virginroad_photo_ver",
+    "odogu_vvlight_photo_ver",
+    "odogu_wasyokuset_gohan_photo_ver",
+    "odogu_wasyokuset_hashi_photo_ver",
+    "odogu_wasyokuset_misoshiru_photo_ver",
+    "odogu_wasyokuset_nimono_photo_ver",
+    "odogu_wasyokuset_ocha_photo_ver",
+    "odogu_yousyokuset_chickenrice_photo_ver",
+    "odogu_yousyokuset_coffee_photo_ver",
+    "odogu_yousyokuset_cornsoup_photo_ver",
+    "odogu_yousyokuset_hamburg_photo_ver",
+    "odogu_yousyokuset_sakiwarespoon_photo_ver"
+]

+ 114 - 0
src/MeidoPhotoStudio.Plugin/Config/MeidoPhotoStudio/Database/face_slider_limits.json

@@ -0,0 +1,114 @@
+/*
+    This file contains the upper limit for face blend values.
+
+    The values must be a real number that is greater than or equal to 1 otherwise they will be ignored.
+
+    The default limit for the majority, if not all, of the blend values used in studio mode is 1 so setting a limit 
+    below that doesn't really make sense.
+*/
+{
+    // Eye Shut
+    "eyeclose": 1,
+    // Eye Smile
+    "eyeclose2": 1,
+    // Glare
+    "eyeclose3": 1,
+    // Wide Eyes
+    "eyebig": 1,
+    // Wink 1
+    "eyeclose6": 1,
+    // Wink 2
+    "eyeclose5": 1,
+    // Highlight
+    "hitomih": 2,
+    // Pupil Size
+    "hitomis": 3,
+    // Brow 1
+    "mayuha": 1,
+    // Brow 2
+    "mayuw": 1,
+    // Brow Up
+    "mayuup": 1,
+    // Brow Down 1
+    "mayuv": 1,
+    // Brow Down 2
+    "mayuvhalf": 1,
+    // Mouth Open 1
+    "moutha": 1,
+    // Mouth Open 2
+    "mouths": 1,
+    // Mouth Narrow
+    "mouthc": 1,
+    // Mouth Widen
+    "mouthi": 1,
+    // Smile
+    "mouthup": 1.4,
+    // Frown
+    "mouthdw": 1,
+    // Mouth Pucker
+    "mouthhe": 1,
+    // Grin
+    "mouthuphalf": 2,
+    // Tongue Out
+    "tangout": 1,
+    // Tongue Up
+    "tangup": 1,
+    // Tongue Base
+    "tangopen": 1
+}
+/*
+These are alternative limits that further expand the range of the sliders.
+These limits expand the expressiveness of the GP-01 FB face that couldn't be achieved with the default limits.
+
+Since these limits would be way too much for non GP-01 FB faces, they aren't the default.
+{
+    // Eye Shut
+    "eyeclose": 1,
+    // Eye Smile
+    "eyeclose2": 1,
+    // Glare
+    "eyeclose3": 3.5,
+    // Wide Eyes
+    "eyebig": 4,
+    // Wink 1
+    "eyeclose6": 1,
+    // Wink 2
+    "eyeclose5": 1,
+    // Highlight
+    "hitomih": 2,
+    // Pupil Size
+    "hitomis": 3,
+    // Brow 1
+    "mayuha": 3,
+    // Brow 2
+    "mayuw": 2,
+    // Brow Up
+    "mayuup": 2,
+    // Brow Down 1
+    "mayuv": 2,
+    // Brow Down 2
+    "mayuvhalf": 2,
+    // Mouth Open 1
+    "moutha": 1.2,
+    // Mouth Open 2
+    "mouths": 1,
+    // Mouth Narrow
+    "mouthc": 1.2,
+    // Mouth Widen
+    "mouthi": 1.5,
+    // Smile
+    "mouthup": 1.5,
+    // Frown
+    "mouthdw": 1.5,
+    // Mouth Pucker
+    "mouthhe": 1.5,
+    // Grin
+    "mouthuphalf": 2,
+    // Tongue Out
+    "tangout": 2,
+    // Tongue Up
+    "tangup": 2,
+    // Tongue Base
+    "tangopen": 2
+}
+*/

+ 529 - 0
src/MeidoPhotoStudio.Plugin/Config/MeidoPhotoStudio/Database/mm_pose_list.json

@@ -0,0 +1,529 @@
+/*
+    This file contains pose categories and their corresponding pose lists.
+
+    This file will initially have all the poses that MultipleMaids has but can be modified.
+    
+    You probably won't ever need to modify this file unless you're some kind of power user.
+
+    The structure of a pose list object is:
+    
+    {
+        "uiName": "poseCategoryName",
+        "poseList": [ "the_list", "of_poses" ] 
+    }
+
+    'uiName' does not need to adhere to camelCase. 
+    Since poses in 'poseList' are actual files in the game, they should be in snake_case and all lower case.
+
+    Do not add custom poses here. They should be placed in the 'Custom Poses' folder.
+*/
+[
+    {
+        "uiName": "normal",
+        "poseList": [
+            "maid_stand01",
+            "pose_taiki_f",
+            "pose_01_f",
+            "pose_02_f",
+            "pose_03_f",
+            "pose_04_f",
+            "pose_ero_01_loop_f",
+            "pose_ero_02_loop_f",
+            "pose_ero_03_loop_f",
+            "pose_ero_04_loop_f",
+            "pose_ero_05_loop_f",
+            "pose_ero_06_loop_f",
+            "pose_kakkoii_01_loop_f",
+            "pose_kakkoii_02_loop_f",
+            "pose_kakkoii_03_loop_f",
+            "pose_kakkoii_04_loop_f",
+            "pose_kakkoii_05_loop_f",
+            "pose_kakkoii_06_loop_f",
+            "pose_kawaii_01_loop_f",
+            "pose_kawaii_02_loop_f",
+            "pose_kawaii_03_loop_f",
+            "pose_kawaii_04_loop_f",
+            "pose_kawaii_05_loop_f",
+            "pose_kawaii_06_loop_f",
+            "edit_pose21_001_f",
+            "edit_pose21_002_f",
+            "edit_pose21_003_f",
+            "edit_pose21_mune_taiki_f",
+            "edit_pose21_mune_tate_f_once_",
+            "edit_pose21_mune_yoko_f_once_"
+        ]
+    },
+    {
+        "uiName": "standing",
+        "poseList": [
+            "maid_dressroom01",
+            "kaiwa_tati_hutuu1_taiki_f",
+            "maid_dressroom02",
+            "poseizi_taiki_f",
+            "maid_dressroom03",
+            "kaiwa_tati_yorokobub_taiki_f",
+            "maid_stand02akireloop",
+            "stand_madogiwa",
+            "kaiwa_tati_akireb_taiki_f",
+            "kaiwa_tati_udekumu_taiki_f",
+            "maid_stand02listenloop",
+            "sys_munehide",
+            "poseizi2_kakusu_taiki_f",
+            "maid_stand03_base",
+            "maid_stand02",
+            "maid_stand02tere",
+            "kaiwa_tati_ubiawase_taiki_f",
+            "kaiwa_tati_teawase_taiki_f",
+            "kaiwa_tati_hakusyu_taiki_2_f",
+            "maid_view1",
+            "maid_comehome2_loop_",
+            "kaiwa_tati_hohokaki_taiki_f",
+            "kaiwa_tati_tere_taiki_f",
+            "sys_muneporo",
+            "kaiwa_tati_yorokobua_taiki_f",
+            "kaiwa_tati_odoroku_taiki_f",
+            "kaiwa_tati_tutorial_1_taiki_f",
+            "maid_stand05",
+            "kaiwa_tati_tutorial_2_taiki_f",
+            "stand_annai",
+            "kaiwa_tati_teofuru_taiki_f",
+            "kaiwa_tati_munetataku_taiki_f",
+            "kaiwa_tati_yubisasu_taiki_f",
+            "kaiwa_tati_iya_taiki_f",
+            "hinpyoukai_gattu_taiki_f",
+            "hinpyoukai_tewatasi_taiki_f",
+            "kaiwa_tati_yorokobu_taiki_f",
+            "kaiwa_tati_akire_taiki_f",
+            "momi_momi_f",
+            "soji_hakisouji",
+            "soji_hataki",
+            "soji_syokkiarai",
+            "work_ryouri_nabe_mazeru",
+            "work_sentakuhosu",
+            "soji_mop_itazurago",
+            "kaiwa_tati_hirumi_taiki_f"
+        ]
+    },
+    {
+        "uiName": "halfStand",
+        "poseList": [
+            "tennis_kamae_f",
+            "kaiwa_tati_ayamaru_taiki_f",
+            "kaiwa_tati_syazai_taiki_f",
+            "stand_akire",
+            "kaiwa_tati_ibalu_taiki_f",
+            "work_kaimono_itazurago",
+            "kaiwa_tati_kuyasi_taiki_2_f",
+            "soji_houki_itazurago",
+            "work_ryouri_houtyou",
+            "work_demukae_itazurago",
+            "kaiwa_tati_mo_taiki_f",
+            "poseizi2_zeccyougo_f",
+            "poseizi_zeccyougo_f",
+            "fukisouji1",
+            "fukisouji1_vibe",
+            "soji_tubo",
+            "soji_mop",
+            "soji_mop_vibe",
+            "work_mizuyari_itazurago",
+            "work_sentakuhosu_itazurago",
+            "soji_hataki_itazurago",
+            "fukisouji1_itazurago",
+            "maidcho_oha1"
+        ]
+    },
+    {
+        "uiName": "kneeling",
+        "poseList": [
+            "senakanagasi_f",
+            "paizuri_taiki_f",
+            "paizuri_fera_shaseigo_f",
+            "osuwariaibu1",
+            "self_ir_kansou_f",
+            "inu_pose_f",
+            "rosyutu_hounyou_taiki_f"
+        ]
+    },
+    {
+        "uiName": "sitting",
+        "poseList": [
+            "work_hansei",
+            "sex_osuwari_taiki",
+            "item_candy0_osuwari",
+            "rosyutu_pose06_f",
+            "sit_bed1",
+            "hanyou_dogeza_taiki_f",
+            "hanyou_dogeza_aisatu_f",
+            "hanyou_dogeza_f"
+        ]
+    },
+    {
+        "uiName": "onAllFours",
+        "poseList": [
+            "inu_taiki_f",
+            "soji_zoukin",
+            "soji_zoukin_itazurago",
+            "massage_f",
+            "mp_arai_taiki_f",
+            "midasinami_esthe_f2",
+            "midasinami_esthe_f"
+        ]
+    },
+    {
+        "uiName": "sittingFloor",
+        "poseList": [
+            "syagami_pose_f",
+            "hanyou_kizetu_f"
+        ]
+    },
+    {
+        "uiName": "sittingChair",
+        "poseList": [
+            "densyasuwari_taiki_f",
+            "ocha_pose_taiki_f"
+        ]
+    },
+    {
+        "uiName": "sittingSofa",
+        "poseList": [
+            "work_kaiwa",
+            "work_kaiwa_itazurago",
+            "work_hon",
+            "work_hon_itazurago",
+            "work_saihou",
+            "work_sentaku_tatamu",
+            "midasinami_kadou_f",
+            "work_mimi_f",
+            "work_mimi_itazurago_f",
+            "sit_yasumi1",
+            "sit_tukue",
+            "sleep1",
+            "kaiwa_sofa_utumuku_taiki_f",
+            "kaiwa_sofa_teawasea_taiki2_f",
+            "kaiwa_sofa_teawase_taiki_f",
+            "kaiwa_sofa_hazukasii_taiki_f",
+            "op_osyaku_taiki_f",
+            "op_wine_taiki_f",
+            "kaiwa_sofa_1_f",
+            "kaiwa_sofa_noridasu_1_taiki_f",
+            "kaiwa_sofa_noridasu_2_taiki_f",
+            "kaiwa_sofa_kangaerua_taiki_f",
+            "kaiwa_sofa_kangaerub_taiki_f",
+            "kaiwa_sofa_kangaeru_taiki_f",
+            "kaiwa_sofa_tere_taiki_f",
+            "kaiwa_sofa_konwakua_taiki_f",
+            "kaiwa_sofa_odoroki_taiki_f",
+            "kaiwa_sofa_konwaku_2_taiki_f",
+            "kaiwa_sofa_konwaku_taiki_f"
+        ]
+    },
+    {
+        "uiName": "walking",
+        "poseList": [
+            "rosyutu_aruki_f_once_,1.37",
+            "rosyutu_aruki_f_once_,2.35",
+            "rosyutu_aruki_omocya_f,1.4",
+            "rosyutu_aruki_omocya_f,2.72",
+            "rosyutu_omocya_aruki_f_once_,1.54",
+            "rosyutu_omocya_aruki_f_once_,2.33"
+        ]
+    },
+    {
+        "uiName": "other",
+        "poseList": [
+            "stand_desk1",
+            "soji_tukue",
+            "soji_tukuefuki_salon"
+        ]
+    },
+    {
+        "uiName": "danceDokiDoki",
+        "poseList": [
+            "dance_cm3d2_001_f1,14.14",
+            "dance_cm3d2_001_f1,18.72",
+            "dance_cm3d2_001_f1,15.34",
+            "dance_cm3d2_001_f1,35.20",
+            "dance_cm3d2_001_f1,36.15",
+            "dance_cm3d2_001_f1,74.72",
+            "dance_cm3d2_001_f1,74.52",
+            "dance_cm3d2_001_f1,74.13",
+            "dance_cm3d2_001_f1,63.53",
+            "dance_cm3d2_001_f1,64.41",
+            "dance_cm3d2_001_f1,80.41",
+            "dance_cm3d2_001_f1,80.62",
+            "dance_cm3d2_001_f1,81.47",
+            "dance_cm3d2_001_f1,68.36",
+            "dance_cm3d2_001_f1,68.49",
+            "dance_cm3d2_001_f1,70.25",
+            "dance_cm3d2_001_f1,70.64",
+            "dance_cm3d2_001_f1,71.36",
+            "dance_cm3d2_001_f1,72.26",
+            "dance_cm3d2_001_f1,72.45",
+            "dance_cm3d2_001_f1,73.23",
+            "dance_cm3d2_001_f1,82.98",
+            "dance_cm3d2_001_f1,83.77",
+            "dance_cm3d2_001_f1,86.05",
+            "dance_cm3d2_001_f1,94.06",
+            "dance_cm3d2_001_f1,94.52",
+            "dance_cm3d2_001_f1,95.0",
+            "dance_cm3d2_001_f1,60.32",
+            "dance_cm3d2_001_f1,60.76",
+            "dance_cm3d2_001_f1,61.36",
+            "dance_cm3d2_001_f1,150.0"
+        ]
+    },
+    {
+        "uiName": "danceEntranceToYou",
+        "poseList": [
+            "dance_cm3d_001_f1,39.25",
+            "dance_cm3d_001_f1,8.29",
+            "dance_cm3d_001_f1,11.47",
+            "dance_cm3d_001_f1,12.67",
+            "dance_cm3d_001_f1,14.42",
+            "dance_cm3d_001_f1,18.45",
+            "dance_cm3d_001_f1,24.43",
+            "dance_cm3d_001_f1,52.57",
+            "dance_cm3d_001_f1,56.83",
+            "dance_cm3d_001_f1,58.18",
+            "dance_cm3d_001_f1,62.87",
+            "dance_cm3d_001_f1,63.84",
+            "dance_cm3d_001_f1,69.52",
+            "dance_cm3d_001_f1,70.52",
+            "dance_cm3d_001_f1,71.31",
+            "dance_cm3d_001_f1,72.67",
+            "dance_cm3d_001_f1,73.94",
+            "dance_cm3d_001_f1,77.55",
+            "dance_cm3d_001_f1,79.78",
+            "dance_cm3d_001_f1,82.56",
+            "dance_cm3d_001_f1,85.71",
+            "dance_cm3d_001_f1,105.82",
+            "dance_cm3d_001_f1,107.48",
+            "dance_cm3d_001_f1,107.92"
+        ]
+    },
+    {
+        "uiName": "danceScarletLeap",
+        "poseList": [
+            "dance_cm3d_002_end_f1,50.71",
+            "dance_cm3d_002_end_f1,53.04",
+            "dance_cm3d_002_end_f1,102.88",
+            "dance_cm3d_002_end_f1,75.18",
+            "dance_cm3d_002_end_f1,79.34",
+            "dance_cm3d_002_end_f1,97.01",
+            "dance_cm3d_002_end_f1,89.85",
+            "dance_cm3d_002_end_f1,26.74",
+            "dance_cm3d_002_end_f1,100.30",
+            "dance_cm3d_002_end_f1,101.38",
+            "dance_cm3d_002_end_f1,124.85",
+            "dance_cm3d_002_end_f1,35.40",
+            "dance_cm3d_002_end_f1,107.98",
+            "dance_cm3d_002_end_f1,106.71",
+            "dance_cm3d_002_end_f1,36.51",
+            "dance_cm3d_002_end_f1,47.54",
+            "dance_cm3d_002_end_f1,118.35",
+            "dance_cm3d_002_end_f1,43.37",
+            "dance_cm3d_002_end_f1,31.22",
+            "dance_cm3d_002_end_f1,90.71",
+            "dance_cm3d_002_end_f1,25.78",
+            "dance_cm3d_002_end_f1,24.85",
+            "dance_cm3d_002_end_f1,29.21",
+            "dance_cm3d_002_end_f1,29.53",
+            "dance_cm3d_002_end_f1,29.72",
+            "dance_cm3d_002_end_f1,128.61",
+            "dance_cm3d_002_end_f1,133.56",
+            "dance_cm3d_002_end_f1,138.26",
+            "dance_cm3d_002_end_f1,63.84",
+            "dance_cm3d_002_end_f1,170"
+        ]
+    },
+    {
+        "uiName": "danceRhythmix",
+        "poseList": [
+            "dance_cm3d_003_sp2_f1,90.15",
+            "dance_cm3d_003_sp2_f1,102.35",
+            "dance_cm3d_003_sp2_f1,66.56",
+            "dance_cm3d_003_sp2_f1,103.36",
+            "dance_cm3d_003_sp2_f1,103.86",
+            "dance_cm3d_003_sp2_f1,105.19",
+            "dance_cm3d_003_sp2_f1,100.05",
+            "dance_cm3d_003_sp2_f1,99.55",
+            "dance_cm3d_003_sp2_f1,19.54",
+            "dance_cm3d_003_sp2_f1,21.34",
+            "dance_cm3d_003_sp2_f1,11.84",
+            "dance_cm3d_003_sp2_f1,14.69",
+            "dance_cm3d_003_sp2_f1,24.44",
+            "dance_cm3d_003_sp2_f1,32.47",
+            "dance_cm3d_003_sp2_f1,47.97",
+            "dance_cm3d_003_sp2_f1,48.38",
+            "dance_cm3d_003_sp2_f1,51.32",
+            "dance_cm3d_003_sp2_f1,56.47",
+            "dance_cm3d_003_sp2_f1,61.64",
+            "dance_cm3d_003_sp2_f1,68.00",
+            "dance_cm3d_003_sp2_f1,69.35",
+            "dance_cm3d_003_sp2_f1,69.80",
+            "dance_cm3d_003_sp2_f1,72.68",
+            "dance_cm3d_003_sp2_f1,77.29",
+            "dance_cm3d_003_sp2_f1,82.81",
+            "dance_cm3d_003_sp2_f1,83.98",
+            "dance_cm3d_003_sp2_f1,92.09",
+            "dance_cm3d_003_sp2_f1,101.40",
+            "dance_cm3d_003_sp2_f1,104.48",
+            "dance_cm3d_003_sp2_f1,106.61",
+            "dance_cm3d_003_sp2_f1,106.78",
+            "dance_cm3d_003_sp2_f1,108.43",
+            "dance_cm3d_003_sp2_f1,109.41",
+            "dance_cm3d_003_sp2_f1,111.23",
+            "dance_cm3d_003_sp2_f1,112.67",
+            "dance_cm3d_003_sp2_f1,112.89",
+            "dance_cm3d_003_sp2_f1,114.03",
+            "dance_cm3d_003_sp2_f1,115.61"
+        ]
+    },
+    {
+        "uiName": "dance",
+        "poseList": [
+            "dance_cm3d_001_f1",
+            "dance_cm3d_002_end_f1",
+            "dance_cm3d_003_sp2_f1",
+            "dance_cm3d2_001_f1",
+            "dance_cm3d2_001_f2",
+            "dance_cm3d2_001_f3",
+            "dance_cm3d21_001_nmf_f1",
+            "dance_cm3d21_001_nmf_f2",
+            "dance_cm3d21_001_nmf_f3",
+            "dance_cm3d21_002_bid_f1",
+            "dance_cm3d21_002_bid_f2",
+            "dance_cm3d21_002_bid_f3",
+            "dance_cm3d21_003_kad_f1",
+            "dance_cm3d21_003_kad_f2",
+            "dance_cm3d21_003_kad_f3",
+            "dance_cm3d21_004_lm_f1",
+            "dance_cm3d21_004_lm_f2",
+            "dance_cm3d21_004_lm_f3",
+            "dance_cm3d21_005_moe_f1",
+            "dance_cm3d21_005_moe_f2",
+            "dance_cm3d21_005_moe_f3"
+        ]
+    },
+    {
+        "uiName": "danceMC",
+        "poseList": [
+            "dance_mc_001_p01a_f1_once_",
+            "dance_mc_001_p01a_f2_once_",
+            "dance_mc_001_p01a_f3_once_",
+            "dance_mc_001_p01b_f1_once_",
+            "dance_mc_001_p01b_f2_once_",
+            "dance_mc_001_p01b_f3_once_",
+            "dance_mc_001_p02_f1_once_",
+            "dance_mc_001_p02_f2_once_",
+            "dance_mc_001_p02_f3_once_",
+            "dance_mc_001_p03_good_f1_once_",
+            "dance_mc_001_p03_good_f2_once_",
+            "dance_mc_001_p03_good_f3_once_",
+            "dance_mc_001_p04_bad_f1_once_",
+            "dance_mc_001_p04_bad_f2_once_",
+            "dance_mc_001_p04_bad_f3_once_",
+            "dance_mc_001_p05_f1_once_",
+            "dance_mc_001_p05_f2_once_",
+            "dance_mc_001_p05_f3_once_",
+            "dance_mc_002_p01_f1_once_",
+            "dance_mc_002_p01_f2_once_",
+            "dance_mc_002_p01_f3_once_",
+            "dance_mc_002_p02_f1_once_",
+            "dance_mc_002_p02_f2_once_",
+            "dance_mc_002_p02_f3_once_",
+            "dance_mc_002_p03_good_f1_once_",
+            "dance_mc_002_p03_good_f2_once_",
+            "dance_mc_002_p03_good_f3_once_",
+            "dance_mc_002_p04_bad_f1_once_",
+            "dance_mc_002_p04_bad_f2_once_",
+            "dance_mc_002_p04_bad_f3_once_",
+            "dance_mc_002_p05_f1_once_",
+            "dance_mc_002_p05_f2_once_",
+            "dance_mc_002_p05_f3_once_",
+            "dance_mc_003_p01_f1_once_",
+            "dance_mc_003_p01_f2_once_",
+            "dance_mc_003_p01_f3_once_",
+            "dance_mc_003_p02_f1_once_",
+            "dance_mc_003_p02_f2_once_",
+            "dance_mc_003_p02_f3_once_",
+            "dance_mc_003_p03_good_f1_once_",
+            "dance_mc_003_p03_good_f2_once_",
+            "dance_mc_003_p03_good_f3_once_",
+            "dance_mc_003_p04_bad_f1_once_",
+            "dance_mc_003_p04_bad_f2_once_",
+            "dance_mc_003_p04_bad_f3_once_",
+            "dance_mc_003_p05_f1_once_",
+            "dance_mc_003_p05_f2_once_",
+            "dance_mc_003_p05_f3_once_",
+            "dance_mc_004_p01_f1_once_",
+            "dance_mc_004_p01_f2_once_",
+            "dance_mc_004_p01_f3_once_",
+            "dance_mc_004_p02_f1_once_",
+            "dance_mc_004_p02_f2_once_",
+            "dance_mc_004_p02_f3_once_",
+            "dance_mc_004_p03_good_f1_once_",
+            "dance_mc_004_p03_good_f2_once_",
+            "dance_mc_004_p03_good_f3_once_",
+            "dance_mc_004_p04_bad_f1_once_",
+            "dance_mc_004_p04_bad_f2_once_",
+            "dance_mc_004_p04_bad_f3_once_",
+            "dance_mc_004_p05_f1_once_",
+            "dance_mc_004_p05_f2_once_",
+            "dance_mc_004_p05_f3_once_",
+            "dance_mc_005_p01_f1_once_",
+            "dance_mc_005_p01_f2_once_",
+            "dance_mc_005_p01_f3_once_",
+            "dance_mc_005_p02_f1_once_",
+            "dance_mc_005_p02_f2_once_",
+            "dance_mc_005_p02_f3_once_",
+            "dance_mc_005_p03_good_f1_once_",
+            "dance_mc_005_p03_good_f2_once_",
+            "dance_mc_005_p03_good_f3_once_",
+            "dance_mc_005_p04_bad_f1_once_",
+            "dance_mc_005_p04_bad_f2_once_",
+            "dance_mc_005_p04_bad_f3_once_",
+            "dance_mc_005_p05_f1_once_",
+            "dance_mc_005_p05_f2_once_",
+            "dance_mc_005_p05_f3_once_"
+        ]
+    },
+    {
+        "uiName": "restraint",
+        "poseList": [
+            "turusi_sex_in_taiki_f",
+            "turusi_sex_shaseigo_naka_f",
+            "turusi_sex_shaseigo_soto_f",
+            "mokuba_sissin_f",
+            "poseizi2_taiki_f",
+            "hentai_pose_03_f",
+            "osuwariaibu2",
+            "rosyutu_pose03_f",
+            "kousoku_aibu_hibu_sissin_taiki_f"
+        ]
+    },
+    {
+        "uiName": "ero",
+        "poseList": [
+            "rosyutu_pose01_f",
+            "rosyutu_pose02_f",
+            "rosyutu_pose04_f",
+            "rosyutu_pose05_f",
+            "rosyutu_taiki_omocya_f",
+            "rosyutu_omocya_taiki_f",
+            "rosyutu_omocya_zeccyougo_f",
+            "rosyutu_tati_vibe_onani_zeccyougo_f",
+            "ran3p_housi_taiki_f",
+            "ran3p_housi_shaseigo_f",
+            "manguri_in_taiki_f",
+            "manguri_shaseigo_naka_f",
+            "manguri_taiki_f",
+            "manguri_shaseigo_soto_f",
+            "ran3p_seijyoui_kuti_shaseigo_soto_f",
+            "ran3p_seijyoui_kuti_sissin_taiki_f",
+            "nefera_shasei_kuti_nomi02_b_f",
+            "ran3p_2ana_in_taiki_f",
+            "haimenrituia_zikkyou_1_f"
+        ]
+    }
+]

+ 212 - 0
src/MeidoPhotoStudio.Plugin/Config/MeidoPhotoStudio/Translations/en/translation.bg.json

@@ -0,0 +1,212 @@
+{
+    "bgNames": {
+        "adultshop": "Adult Shop",
+        "aquarium": "Aquarium",
+        "aquarium_isu": "Aquarium (No Chairs)",
+        "BackStage": "Backstage",
+        "Bar": "Bar",
+        "BarLounge": "Bar Lounge",
+        "Bathroom": "Bathroom",
+        "BigSight": "Cosplay Convention",
+        "BigSight_Night": "Cosplay Convention (Night)",
+        "boutique": "Boutique",
+        "campsite_day": "Campsite (Day)",
+        "campsite_dust": "Campsite (Dusk)",
+        "campsite_night": "Campsite (Night)",
+        "Casino": "Casino",
+        "CasinoMini": "Mini Casino",
+        "cathedral": "Cathedral",
+        "ClassRoom": "Classroom",
+        "ClassRoom_Play": "Classroom (Yotogi)",
+        "classroom_nodesk": "Classroom (No Desks)",
+        "com3d2pool": "Pool",
+        "com3d2pool_night": "Pool (Night)",
+        "DanceRoom": "Training",
+        "DressRoom_NoMirror": "Dressing Room",
+        "dubiousbar": "Dubious Bar",
+        "empireclub_elevator": "Empire Club Elevator",
+        "EmpireClub_Entrance": "Empire Club Entrance",
+        "empireclub_hallway": "Empire Club Hallway",
+        "EmpireClub_Rotary": "Empire Club Rotary",
+        "EmpireClub_Rotary_Night": "Empire Club Rotary (Night)",
+        "fantasyinn": "Fantasy Inn",
+        "fantasyinn_night": "Fantasy Inn (Night)",
+        "GameShop": "Game Shop",
+        "gelaende": "Ski Slope",
+        "gelaende_night": "Ski Slope (Night)",
+        "HeroineRoom_A": "Innocence Room",
+        "HeroineRoom_A_Night": "Innocence Room (Night)",
+        "HeroineRoom_A1": "Pure Room",
+        "HeroineRoom_A1_Night": "Pure Room (Night)",
+        "HeroineRoom_B": "Kuudere Room",
+        "HeroineRoom_B_Night": "Kuudere Room (Night)",
+        "HeroineRoom_B1": "Serious Room",
+        "HeroineRoom_B1_Night": "Serious Room (Night)",
+        "HeroineRoom_C": "Tsundere",
+        "HeroineRoom_C_Night": "Tsundere (Night)",
+        "HeroineRoom_C1": "Rindere Room",
+        "HeroineRoom_C1_Night": "Rindere Room (Night)",
+        "HeroineRoom_D": "Yandere Room",
+        "HeroineRoom_D_Night": "Yandere Room (Night)",
+        "HeroineRoom_D1": "Bookworm Room",
+        "HeroineRoom_D1_Night": "Bookworm Room (Night)",
+        "HeroineRoom_E": "Oneechan Room",
+        "HeroineRoom_E_Night": "Oneechan Room (Night)",
+        "HeroineRoom_E1": "Koakuma Room",
+        "HeroineRoom_E1_Night": "Koakuma Room (Night)",
+        "HeroineRoom_F": "Genki Room",
+        "HeroineRoom_F_Night": "Genki Room (Night)",
+        "HeroineRoom_F1": "Anesan Room",
+        "HeroineRoom_F1_Night": "Anesan Room (Night)",
+        "HeroineRoom_G": "Sadist Room",
+        "HeroineRoom_G_Night": "Sadist Room (Night)",
+        "HeroineRoom_G1": "Secretary Room",
+        "HeroineRoom_G1_Night": "Secretary Room (Night)",
+        "HeroineRoom_H1": "Imouto Room",
+        "HeroineRoom_H1_Night": "Imouto Room (Night)",
+        "HeroineRoom_J1": "Wary Room",
+        "HeroineRoom_J1_Night": "Wary Room (Night)",
+        "HeroineRoom_K1": "Ojou Room",
+        "HeroineRoom_K1_Night": "Ojou Room (Night)",
+        "HeroineRoom_L1": "Osanajimi Room",
+        "HeroineRoom_L1_Night": "Osanajimi Room (Night)",
+        "homelesstents": "Homeless Tent",
+        "HoneymoonRoom": "Honeymoon Room",
+        "izakaya": "Japanese Bar",
+        "izakaya_play": "Japanese Bar Messy",
+        "japanesehouse": "Japanese Style House",
+        "japanesehouse_night": "Japanese Style House (Night)",
+        "KaraokeRoom": "Karaoke Room",
+        "Kitchen": "Kitchen",
+        "Kitchen_Night": "Kitchen (Night)",
+        "LargeBathRoom": "Large Bathroom",
+        "LiveStage": "Live Stage",
+        "LiveStage_Side": "Live Stage (On)",
+        "LiveStage_use_dance": "Live Stage (Off)",
+        "LockerRoom": "Locker Room",
+        "luxurytoilet": "Luxury Bathroom",
+        "machikado": "Street Corner",
+        "machikado_night": "Street Corner (Night)",
+        "MaidRoom": "Maid Room",
+        "MainKitchen": "Main Kitchen",
+        "MainKitchen_LightOff": "Main Kitchen (Lights Off)",
+        "MainKitchen_Night": "Kitchen (Night)",
+        "MusicShop": "Music Shop",
+        "MyBedRoom": "Master's Room",
+        "MyBedRoom_Night": "Master's Room (Night)",
+        "MyBedRoom_NightOff": "Master's Room (Lights Off)",
+        "MyRoom": "Master's Room",
+        "MyRoom_Night": "Master's Room Night",
+        "Oheya": "Tatami Mat Room",
+        "OiranRoom": "Oiran Style Room",
+        "OpemCafe": "Open Cafe",
+        "opemcafe_aikiss": "Ai Kiss Collab Cafe",
+        "opemcafe_aikiss_night": "Ai Kiss Collab Cafe (Night)",
+        "opemcafe_cristalia": "CRYSTALiA Collab Cafe",
+        "opemcafe_cristalia_night": "CRYSTALiA Collab Cafe (Night)",
+        "opemcafe_evenicle2": "Evenicle Ⅱ Collab Cafe",
+        "opemcafe_evenicle2_night": "Evenicle Ⅱ Collab Cafe (Night)",
+        "opemcafe_inuyome2": "Wanko no Yomeiri Collab Cafe 2",
+        "opemcafe_inuyome2_night": "Wanko no Yomeiri Collab Cafe 2 (Night)",
+        "opemcafe_korolum": "Korolum Collab Cafe",
+        "opemcafe_korolum2": "Korolum Collab Cafe 2",
+        "opemcafe_korolum2_night": "Korolum Collab Cafe 2 (Night)",
+        "opemcafe_korolum_night": "Korolum Collab Cafe (Night)",
+        "opemcafe_laplacian": "Future Radio and Artificial Pigeons Collab Cafe",
+        "opemcafe_laplacian_night": "Future Radio and Artificial Pigeons Collab Cafe (Night)",
+        "opemcafe_nekow": "Nekopara Collab Cafe",
+        "opemcafe_nekow_night": "Nekopara Collab Cafe (Night)",
+        "OpemCafe_Night": "Open Cafe (Night)",
+        "opemcafe_nitro": "Minikui Mojika no Ko Collab Cafe",
+        "opemcafe_nitro_night": "Minikui Mojika no Ko Collab Cafe (Night)",
+        "opemcafe_pencil": "Mary-san Collab Cafe",
+        "opemcafe_pencil_night": "Mary-san Collab Cafe (Night)",
+        "opemcafe_rance10": "RanceX Collab Cafe",
+        "opemcafe_rance10_night": "RanceX Collab Cafe (Night)",
+        "opemcafe_raspberry": "Raspberry Cube Collab Cafe",
+        "opemcafe_raspberry_night": "Raspberry Cube Collab Cafe (Night)",
+        "opemcafe_riddlejoker": "Riddle Joker Collab Cafe",
+        "opemcafe_riddlejoker_night": "Riddle Joker Collab Cafe (Night)",
+        "opemcafe_sagapla": "Kin'iro Loveriche GT Collab Cafe",
+        "opemcafe_sagapla_night": "Kin'iro Loveriche GT Collab Cafe (Night)",
+        "opemcafe_wanko": "Wanko no Yomeiri Collab Cafe",
+        "opemcafe_wanko_night": "Wanko no Yomeiri Collab Cafe (Night)",
+        "OutletPark": "Outlet Park",
+        "park": "Park (No Food Trucks)",
+        "park_car": "Park",
+        "park_car_night": "Park (Night)",
+        "park_night": "Park (Night No Food Trucks)",
+        "Penthouse": "Penthouse",
+        "PlayRoom": "Playroom",
+        "PlayRoom2": "Playroom 2",
+        "poledancestage": "Pole Dance Stage",
+        "Pool": "Pool",
+        "PrivateRoom": "Private Room",
+        "privateroom2": "Private Room 2",
+        "privateroom2_night": "Private Room 2 (Night)",
+        "privateroom2_nightoff": "Private Room 2 (Lights Off)",
+        "PrivateRoom_Night": "Private Room (Night)",
+        "Restaurant": "Restaurant",
+        "Restaurant_Night": "Restaurant (Night)",
+        "Rotenburo": "Open-Air Bath",
+        "Rotenburo_Night": "Open-Air Bath (Night)",
+        "Salon": "Salon",
+        "Salon_Day": "Salon Day",
+        "Salon_Entrance": "Salon Entrance",
+        "Salon_Garden": "Courtyard",
+        "Sea": "Beach",
+        "seacafe": "Beach Cafe",
+        "seacafe_night": "Beach Cafe (Night)",
+        "Sea_Night": "Beach (Night)",
+        "Sea_VR": "Beach (VR)",
+        "Sea_VR_Night": "Beach (VR Night)",
+        "ShinShitsumu": "Office",
+        "ShinShitsumu_ChairRot": "Office (Chair)",
+        "ShinShitsumu_Night": "Office (Night)",
+        "Shitsumu": "Office",
+        "Shitsumu_ChairRot": "Office 2 (Chair)",
+        "Shitsumu_ChairRot_Night": "Office 2 (Chair Night)",
+        "Shitsumu_Night": "Office (Night)",
+        "ShoppingMall": "Shopping Mall",
+        "ShoppingMall_Night": "Shopping Mall (Night)",
+        "shrine": "Shrine",
+        "shrine_night": "Shrine (Night)",
+        "Shukuhakubeya_BedRoom": "Bedroom",
+        "Shukuhakubeya_BedRoom_Night": "Bedroom (Night)",
+        "Shukuhakubeya_Living": "Living Room",
+        "Shukuhakubeya_Living_Night": "Living Room (Night)",
+        "Shukuhakubeya_Other_BedRoom": "Bedroom 2",
+        "Shukuhakubeya_Toilet": "Bathroom",
+        "Shukuhakubeya_Toilet_Night": "Bathroom (Night)",
+        "Shukuhakubeya_WashRoom": "Washroom",
+        "Shukuhakubeya_WashRoom_Night": "Washroom (Night)",
+        "SMClub": "SM Club",
+        "SMRoom": "SM Room",
+        "SMRoom2": "Basement",
+        "Soap": "Soapland",
+        "Spa": "Spa",
+        "Spa_Night": "Spa (Night)",
+        "springgarden": "Spring Garden (No Stalls)",
+        "springgarden_night": "Spring Garden (Night No Stalls)",
+        "springgarden_yatai": "Spring Garden",
+        "springgarden_yatai_night": "Spring Garden (Night)",
+        "Syosai": "Study",
+        "Syosai_Night": "Study (Night)",
+        "Theater": "Theater",
+        "Theater_LightOff": "Theater (Night)",
+        "Toilet": "Toilet",
+        "Town": "Town",
+        "Train": "Train",
+        "train_notsurikawa": "Train (No Handles)",
+        "Villa": "Villa 1F",
+        "Villa_BedRoom": "Villa 2f",
+        "Villa_BedRoom_Night": "Villa 2f (Night)",
+        "villa_bedroom_door": "Villa 2f (Door)",
+        "Villa_Farm": "Garden",
+        "Villa_Farm_Night": "Garden (Night)",
+        "Villa_Night": "Villa 1F (Night)",
+        "Yashiki": "Inn (Night)",
+        "Yashiki_Day": "Inn",
+        "Yashiki_Pillow": "Inn (Night with Pillow)"
+    }
+}

+ 121 - 0
src/MeidoPhotoStudio.Plugin/Config/MeidoPhotoStudio/Translations/en/translation.face.json

@@ -0,0 +1,121 @@
+{
+    "faceBlendPresetsDropdown": {
+        "通常": "Normal",
+        "微笑み": "Half Smile",
+        "笑顔": "Big Smile",
+        "にっこり": "Sweet Smile",
+        "優しさ": "Gentle",
+        "発情": "Lust",
+        "ジト目": "Glare",
+        "閉じ目": "Eyes Closed",
+        "思案伏せ目": "Consideration",
+        "ドヤ顔": "Proud",
+        "引きつり笑顔": "Awkward Smile",
+        "苦笑い": "Bitter Smile",
+        "困った": "Distressed",
+        "疑問": "Curious",
+        "ぷんすか": "Upset",
+        "むー": "Mmmm..",
+        "泣き": "Cry",
+        "拗ね": "Sulk",
+        "照れ": "Bashful",
+        "悲しみ2": "Sad",
+        "きょとん": "Confused",
+        "びっくり": "Surprised",
+        "少し怒り": "Kinda Angry",
+        "怒り": "Angry",
+        "照れ叫び": "Shout",
+        "誘惑": "Temptation",
+        "接吻": "Kiss",
+        "居眠り安眠": "Sleep",
+        "まぶたギュ": "Eyes Shut",
+        "目を見開いて": "Eyes Wide Open",
+        "痛みで目を見開いて": "Eyes Open Pain",
+        "恥ずかしい": "Embarrassed",
+        "ためいき": "Sigh",
+        "目口閉じ": "Eyes and Mouth Closed",
+        "ウインク照れ": "Shy Wink",
+        "ダンス目つむり": "Eyes and Mouth Closed",
+        "ダンスあくび": "Yawn",
+        "ダンスびっくり": "Surprised",
+        "ダンス微笑み": "Half Smile",
+        "ダンス目あけ": "Eyes Open",
+        "ダンス目とじ": "Eyes Closed",
+        "ダンス誘惑": "Temptation",
+        "ダンス困り顔": "Troubled",
+        "ダンスウインク": "Wink",
+        "ダンス真剣": "Serious",
+        "ダンス憂い": "Sorrow",
+        "ダンスジト目": "Glare",
+        "ダンスキス": "Kiss",
+        "エロ通常1": "General 1",
+        "エロ通常2": "General 2",
+        "エロ通常3": "General 3",
+        "エロ興奮0": "Excitement 1",
+        "エロ興奮1": "Excitement 2",
+        "エロ興奮2": "Excitement 3",
+        "エロ興奮3": "Excitement 4",
+        "エロ好感1": "Favourable 1",
+        "エロ好感2": "Favourable 2",
+        "エロ好感3": "Favourable 3",
+        "エロ期待": "Anticipation",
+        "エロ羞恥1": "Bashful 1",
+        "エロ羞恥2": "Bashful 2",
+        "エロ羞恥3": "Bashful 3",
+        "エロ緊張": "Nervous",
+        "エロ我慢1": "Endurance 1",
+        "エロ我慢2": "Endurance 2",
+        "エロ我慢3": "Endurance 3",
+        "エロ嫌悪1": "Hate",
+        "エロ痛み1": "Pain 1",
+        "エロ痛み2": "Pain 2",
+        "エロ痛み3": "Pain 3",
+        "エロ痛み我慢": "Enduring 1",
+        "エロ痛み我慢2": "Enduring 2",
+        "エロ痛み我慢3": "Enduring 3",
+        "エロ怯え": "Afraid",
+        "エロメソ泣き": "Lewd Cry",
+        "あーん": "Ahhnn",
+        "エロ舌責": "Tongue Out",
+        "エロ舌責快楽": "Tongue Out Pleasure",
+        "エロ舌責嫌悪": "Tongue Out Hate",
+        "エロ舐め通常": "Lick Normal 1",
+        "エロ舐め通常2": "Lick Normal 2",
+        "エロ舐め愛情": "Lick Love 1",
+        "エロ舐め愛情2": "Lick Love 2",
+        "エロ舐め快楽": "Lick Pleasure 1",
+        "エロ舐め快楽2": "Lick Pleasure 2",
+        "エロ舐め嫌悪": "Lick Hate 1",
+        "エロ舐め嫌悪2": "Lick Hate 2",
+        "エロフェラ通常": "Fellatio Normal",
+        "エロフェラ愛情": "Fellatio Love",
+        "エロフェラ快楽": "Fellatio Pleasure",
+        "エロフェラ嫌悪": "Fellatio Hate",
+        "閉じ舐め通常": "Lick Normal 1 (close)",
+        "閉じ舐め通常2": "Lick Normal 2 (close)",
+        "閉じ舐め愛情": "Lick Love 1 (close)",
+        "閉じ舐め愛情2": "Lick Love 2 (close)",
+        "閉じ舐め快楽": " Lick Pleasure 1 (close)",
+        "閉じ舐め快楽2": "Lick Pleasure 2 (close)",
+        "閉じ舐め嫌悪": "Lick Hate 1 (close)",
+        "閉じ舐め嫌悪2": "Lick Hate 2 (close)",
+        "閉じフェラ通常": "Fellatio Normal (close)",
+        "閉じフェラ愛情": "Fellatio Love (close)",
+        "閉じフェラ快楽": "Fellatio Pleasure (close)",
+        "閉じフェラ嫌悪": "Fellatio Hate (close)",
+        "通常射精後1": "After Ejaculation 1",
+        "通常射精後2": "After Ejaculation 2",
+        "絶頂射精後1": "After Ejaculation Orgasm 1",
+        "絶頂射精後2": "After Ejaculation Orgasm 2",
+        "興奮射精後1": "After Ejaculation Aroused 1",
+        "興奮射精後2": "After Ejaculation Aroused 2",
+        "余韻弱": "Afterglow",
+        "エロ絶頂": "Orgasm",
+        "エロ放心": "Absent Minded"
+    },
+    "faceBlendCategory": {
+        "一般": "General",
+        "ダンス": "Dance",
+        "エロ": "Ero"
+    }
+}

File diff suppressed because it is too large
+ 1186 - 0
src/MeidoPhotoStudio.Plugin/Config/MeidoPhotoStudio/Translations/en/translation.props.json


+ 443 - 0
src/MeidoPhotoStudio.Plugin/Config/MeidoPhotoStudio/Translations/en/translation.ui.json

@@ -0,0 +1,443 @@
+{
+    "tabs": {
+        "call": "Call",
+        "pose": "Pose",
+        "face": "Face",
+        "bg": "Env",
+        "bg2": "Props"
+    },
+    "maidCallWindow": {
+        "okButton": "OK",
+        "clearButton": "Clear",
+        "callButton": "Call"
+    },
+    "placementDropdown": {
+        "normal": "Normal",
+        "horizontalRow": "Horizontal Row",
+        "verticalRow": "Vertical Row",
+        "diagonalRow": "Diagonal",
+        "diagonalRowInverse": "Diagonal (Inverse)",
+        "circleOuter": "Circle (Outer)",
+        "circleInner": "Circle (Inner)",
+        "fanInner": "Fan (Inner)",
+        "fanOuter": "Fan (Outer)",
+        "v": "V",
+        "vInverse": "^",
+        "wave": "M",
+        "waveInverse": "W"
+    },
+    "maidPoseWindow": {
+        "ikToggle": "IK",
+        "releaseToggle": "Release",
+        "boneToggle": "Bone",
+        "flipToggle": "Flip IK",
+        "fixSkirtToggle": "Fix Skirt"
+    },
+    "poseGroupDropdown": {
+        "normal": "Normal",
+        "standing": "Standing",
+        "halfStand": "Half-Standing",
+        "kneeling": "Kneeling",
+        "sitting": "Sitting",
+        "onAllFours": "On All Fours",
+        "sittingFloor": "Sitting (Floor)",
+        "sittingChair": "Sitting (Chair)",
+        "sittingSofa": "Sitting (Sofa)",
+        "walking": "Walking",
+        "other": "Other",
+        "danceDokiDoki": "Doki Doki ☆ Fallin' Love",
+        "danceEntranceToYou": "Entrance To You",
+        "danceScarletLeap": "Scarlet Leap",
+        "danceRhythmix": "Rhythmix to You",
+        "dance": "Dance",
+        "danceMC": "Dance (MC)",
+        "restraint": "Restraint",
+        "ero": "Ero",
+        "normal2": "Normal 2",
+        "ero2": "Ero 2"
+    },
+    "posePane": {
+        "categoryHeader": "Category",
+        "nameHeader": "Name",
+        "saveToggle": "Save Pose",
+        "saveButton": "Add",
+        "deleteButton": "D",
+        "baseTab": "Base",
+        "customTab": "Custom"
+    },
+    "freeLookPane": {
+        "freeLookToggle": "F-Look",
+        "headToCamToggle": "Head",
+        "eyeToCamToggle": "Eye",
+        "xSlider": "Look X",
+        "ySlider": "Look Y",
+        "bindLabel": "Bind"
+    },
+    "attachMpnPropPane": {
+        "attachButton": "Attach",
+        "detachButton": "Detach",
+        "detachAllButton": "Detach All",
+        "header": "SM Restraints"
+    },
+    "clothing": {
+        "noCategory": " --- ",
+        "accHat": "Hat",
+        "headset": "Headdress",
+        "wear": "Top",
+        "skirt": "Bottoms",
+        "onepiece": "One-Piece",
+        "mizugi": "Bathing Suit",
+        "glove": "Gloves",
+        "shoes": "Shoes",
+        "stkg": "Socks",
+        "bra": "Bra",
+        "panz": "Panties",
+        "megane": "Eyewear",
+        "accHead": "Eye Mask",
+        "accSenaka": "Back",
+        "accShippo": "Tail",
+        "accKami": "Hairpin",
+        "accKami_1_": "Pin 1",
+        "accKami_2_": "Pin 2",
+        "accKami_3_": "Pin 3",
+        "accKamiSub": "Ribbon",
+        "accKamiSubL": "Ribbon L",
+        "accKamiSubR": "Ribbon R",
+        "accMimi": "Ears",
+        "accMiMiL": "Ear L",
+        "accMiMiR": "Ear R",
+        "accHana": "Nose",
+        "accKubiwa": "Choker",
+        "accKubi": "Necklace",
+        "accNip": "Nipples",
+        "accNipL": "Nip L",
+        "accNipR": "Nip R",
+        "accUde": "Arms",
+        "accHeso": "Navel",
+        "accAshi": "Ankles",
+        "accXXX": "Genitals",
+        "body": "Body",
+        "headwear": "Headwear",
+        "curlingFront": "Curl Front",
+        "curlingBack": "Curl Rear",
+        "shiftPanties": "Shift",
+        "detail": "Detailed Clothing",
+        "all": "All",
+        "underwear": "Underwear",
+        "nude": "Nude"
+    },
+    "gravityControlPane": {
+        "hairToggle": "Hair",
+        "skirtToggle": "Skirt",
+        "globalToggle": "Global",
+        "gravityHeader": "Gravity Control"
+    },
+    "handPane": {
+        "header": "Hand Preset",
+        "saveToggle": "Save Hand",
+        "categoryHeader": "Category",
+        "nameHeader": "Name",
+        "saveLeftButton": "Left",
+        "saveRightButton": "Right",
+        "rightHand": "Right",
+        "leftHand": "Left",
+        "noPresetsMessage": "No Hand Presets"
+    },
+    "copyPosePane": {
+        "header": "Copy IK",
+        "copyButton": "Copy"
+    },
+    "flipIK": {
+        "header": "Flip IK",
+        "flipButton": "Flip"
+    },
+    "voiceLines": {
+        "speak": "Speak",
+        "speakEro": "H Lines"
+    },
+    "maidFaceWindow": {
+        "savePaneToggle": "Save",
+        "baseTab": "Base",
+        "customTab": "Custom"
+    },
+    "faceBlendValues": {
+        "eyeclose": "Eye Shut",
+        "eyeclose2": "Eye Smile",
+        "eyeclose3": "Glare",
+        "eyebig": "Eyes Widen",
+        "eyeclose6": "Wink Smile",
+        "eyeclose5": "Wink Shut",
+        "hitomih": "Highlight",
+        "hitomis": "Pupil Size",
+        "mayuha": "Brows Angle",
+        "mayuw": "Brows Sad",
+        "mayuup": "Brows Up",
+        "mayuv": "Brows Angry 1",
+        "mayuvhalf": "Brows Angry 2",
+        "moutha": "Mouth Open 1",
+        "mouths": "Mouth Open 2",
+        "mouthc": "Mouth Narrow",
+        "mouthi": "Mouth Widen",
+        "mouthup": "Smile",
+        "mouthdw": "Frown",
+        "mouthhe": "Pout",
+        "mouthuphalf": "Grin",
+        "tangout": "Tongue Out",
+        "tangup": "Tongue Up",
+        "tangopen": "Tongue Base",
+        "hoho2": "Blush",
+        "shock": "Shade",
+        "nosefook": "Nose Up",
+        "namida": "Tears",
+        "yodare": "Drool",
+        "toothoff": "Teeth",
+        "tear1": "Cry 1",
+        "tear2": "Cry 2",
+        "tear3": "Cry 3",
+        "hohos": "Blush 1",
+        "hoho": "Blush 2",
+        "hohol": "Blush 3"
+    },
+    "faceSave": {
+        "categoryHeader": "Category",
+        "nameHeader": "Name",
+        "saveButton": "Add",
+        "deleteButton": "D"
+    },
+    "backgroundWindow": {
+        "saveLoadButton": "Save\nLoad",
+        "manageScenesButton": "Manage Scenes",
+        "lightLabel": "Light",
+        "red": "Red",
+        "green": "Green",
+        "blue": "Blue"
+    },
+    "propsPane": {
+        "props1AddButton": "Add",
+        "props2AddButton": "Add",
+        "header": "Props"
+    },
+    "propsPaneTabs": {
+        "props": "Props",
+        "myRoom": "MyRoom",
+        "mod": "Mods"
+    },
+    "myRoomPane": {
+        "header": "My Room"
+    },
+    "modPropsPane": {
+        "header": "Mod Props"
+    },
+    "propManagerPane": {
+        "header": "Manage Props",
+        "dragPointToggle": "Cube",
+        "gizmoToggle": "Gizmo",
+        "shadowCastingToggle": "Shadow",
+        "copyButton": "Copy",
+        "deleteButton": "Delete"
+    },
+    "movementCube": {
+        "header": "Movement Cube",
+        "props": "Props",
+        "small": "Small",
+        "maid": "Maid",
+        "bg": "BG"
+    },
+    "lights": {
+        "x": "Light X",
+        "y": "Light Y",
+        "intensity": "Brightness",
+        "range": "Range",
+        "spot": "Spot Angle",
+        "shadow": "Shadow"
+    },
+    "cameraPane": {
+        "header": "Camera",
+        "zRotation": "Z Rotation",
+        "fov": "FOV"
+    },
+    "lightsPane": {
+        "header": "Lights",
+        "add": "+",
+        "delete": "Del",
+        "resetPosition": "Position",
+        "resetProperties": "Properties",
+        "clear": "Clear",
+        "colour": "Colour",
+        "disable": "On",
+        "resetLabel": "Reset"
+    },
+    "lightType": {
+        "main": "Main",
+        "normal": "Normal",
+        "spot": "Spot",
+        "point": "Point"
+    },
+    "effectsPane": {
+        "onToggle": "On",
+        "bloom": "Bloom",
+        "dof": "DOF",
+        "vignette": "Blur",
+        "fog": "Fog",
+        "sepia": "Sepia",
+        "reset": "Reset Effect"
+    },
+    "effectBloom": {
+        "intensity": "Intensity",
+        "blur": "Blur",
+        "hdrToggle": "HDR"
+    },
+    "effectDof": {
+        "focalLength": "Focal Length",
+        "focalArea": "Focal Area",
+        "aperture": "Aperture",
+        "blur": "Blur",
+        "thicknessToggle": "Thickness"
+    },
+    "effectVignette": {
+        "intensity": "Strength",
+        "blur": "Blur",
+        "blurSpread": "Spread",
+        "aberration": "Aberration"
+    },
+    "effectFog": {
+        "distance": "Distance",
+        "density": "Density",
+        "strength": "Strength",
+        "height": "Height"
+    },
+    "effectSepia": {
+        "toggle": "Sepia",
+        "blur": "Blur"
+    },
+    "otherEffectsPane": {
+        "sepiaToggle": "Sepia",
+        "blurSlider": "Blur"
+    },
+    "background2Window": {
+        "envButton": "Environments",
+        "itemLabel": "Item",
+        "smallBGLabel": "BG (Prop)",
+        "propLabel": "Clothes",
+        "modsToggle": "Mods",
+        "baseToggle": "COM3D2"
+    },
+    "clothingDropdown": {
+        "myRoom": "My Room Custom",
+        "hat": "Hat",
+        "headset": "Headset",
+        "top": "Top",
+        "bottom": "Bottom",
+        "onepiece": "One-Piece",
+        "swimsuit": "Swimsuit",
+        "bra": "Bra",
+        "panties": "Panties",
+        "socks": "Socks",
+        "shoes": "Shoes",
+        "hairAccessory": "Hair Accessory",
+        "glasses": "Glasses",
+        "eyeMask": "Eye Mask",
+        "nose": "Nose",
+        "ear": "Ear",
+        "gloves": "Gloves",
+        "necklace": "Necklace",
+        "choker": "Choker",
+        "ribbon": "Ribbon",
+        "nipple": "Nipple",
+        "arms": "Arms",
+        "belly": "Belly",
+        "ankle": "Ankle",
+        "back": "Back",
+        "tail": "Tail",
+        "genitals": "Genitals"
+    },
+    "attachPropPane": {
+        "keepWorldPosition": "Keep World Position",
+        "header": "Attach Point",
+        "head": "Head",
+        "neck": "Neck",
+        "upperArmL": "Shoulder L",
+        "upperArmR": "Shoulder R",
+        "forearmL": "Elbow L",
+        "forearmR": "Elbow R",
+        "muneL": "Mune L",
+        "muneR": "Mune R",
+        "handL": "Hand L",
+        "handR": "Hand R",
+        "pelvis": "Pelvis",
+        "thighL": "Thigh L",
+        "thighR": "Thigh R",
+        "calfL": "Knee L",
+        "calfR": "Knee R",
+        "footL": "Foot L",
+        "footR": "Foot R",
+        "spine1a": "Spine 1",
+        "spine1": "Spine 2",
+        "spine0a": "Spine 3",
+        "spine0": "Spine 4"
+    },
+    "sceneManagerModal": {
+        "deleteDirectoryConfirm": "Are you sure you want to permanently delete '{0}' and all of its files?",
+        "deleteFileConfirm": "Are you sure you want to permanently delete '{0}'?",
+        "deleteDirectoryButton": "Delete Folder",
+        "deleteFileCommit": "Delete Scene",
+        "fileLoadCommit": "Load Scene",
+        "infoKankyo": "Kankyo",
+        "infoMaidSingular": "{0} Maid",
+        "infoMaidPlural": "{0} Maids"
+    },
+    "sceneManager": {
+        "createDirectoryButton": "New Folder",
+        "deleteDirectoryButton": "Delete",
+        "kankyoToggle": "Backgrounds",
+        "refreshButton": "Refresh",
+        "sortName": "Name",
+        "sortCreated": "Date Created",
+        "sortModified": "Date Modified",
+        "descendingToggle": "Descending",
+        "sortLabel": "Sort"
+    },
+    "messageWindow": {
+        "name": "Name",
+        "fontSize": "Font Size",
+        "okButton": "OK"
+    },
+    "systemMessage": {
+        "exitConfirm": "Are you sure you want to quit {0}?\nAny unsaved data will be lost.",
+        "initializing": "Initializing",
+        "noMaids": "No Maids",
+        "noProps": "No Props"
+    },
+    "controls": {
+        "activate": "Activate MPS",
+        "screenshot": "Screenshot",
+        "toggleUI": "Hide/Show UI",
+        "toggleMessage": "Message Box",
+        "meidoUndressing": "Undressing",
+        "cameraLayer": "Camera Layer",
+        "cameraReset": "Reset Camera",
+        "cameraSave": "Save Camera",
+        "cameraLoad": "Load Camera",
+        "dragSelect": "Select",
+        "dragDelete": "Delete",
+        "dragMove": "Move",
+        "dragRotate": "Rotate",
+        "dragScale": "Scale",
+        "dragFinger": "Show Fingers",
+        "saveScene": "Quick Save",
+        "loadScene": "Quick Load",
+        "openSceneManager": "Open Scene Manager"
+    },
+    "settingsLabels": {
+        "reloadTranslation": "Reload Translation",
+        "reloadAllPresets": "Reload Presets"
+    },
+    "settingsHeaders": {
+        "controls": "Controls",
+        "controlsGeneral": "General",
+        "controlsMaids": "Maids",
+        "controlsCamera": "Camera",
+        "controlsDragPoint": "Drag Handles",
+        "controlsScene": "Scene Management"
+    }
+}

+ 15 - 0
src/MeidoPhotoStudio.Plugin/Configuration.cs

@@ -0,0 +1,15 @@
+using BepInEx.Configuration;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public static class Configuration
+    {
+        public static ConfigFile Config { get; }
+
+        static Configuration()
+        {
+            string configPath = System.IO.Path.Combine(Constants.configPath, $"{MeidoPhotoStudio.pluginName}.cfg");
+            Config = new ConfigFile(configPath, false);
+        }
+    }
+}

File diff suppressed because it is too large
+ 1053 - 0
src/MeidoPhotoStudio.Plugin/Constants.cs


+ 146 - 0
src/MeidoPhotoStudio.Plugin/DragPoint/CustomGizmo.cs

@@ -0,0 +1,146 @@
+using System;
+using System.Reflection;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class CustomGizmo : GizmoRender
+    {
+        private static readonly Camera camera = GameMain.Instance.MainCamera.camera;
+        private Transform target;
+        private readonly FieldInfo beSelectedType = Utility.GetFieldInfo<GizmoRender>("beSelectedType");
+        private int SelectedType => (int)beSelectedType.GetValue(this);
+        private static readonly FieldInfo is_drag_ = Utility.GetFieldInfo<GizmoRender>("is_drag_");
+        public static bool IsDrag
+        {
+            get => (bool)is_drag_.GetValue(null);
+            private set => is_drag_.SetValue(null, value);
+        }
+        private Vector3 positionOld = Vector3.zero;
+        private Vector3 deltaPosition = Vector3.zero;
+        private Vector3 deltaLocalPosition = Vector3.zero;
+        private Quaternion rotationOld = Quaternion.identity;
+        private Quaternion deltaRotation = Quaternion.identity;
+        private Quaternion deltaLocalRotation = Quaternion.identity;
+        private Vector3 deltaScale = Vector3.zero;
+        private Vector3 scaleOld = Vector3.one;
+        private GizmoType gizmoTypeOld;
+        private GizmoType gizmoType;
+        public GizmoType CurrentGizmoType
+        {
+            get => gizmoType;
+            set
+            {
+                gizmoType = value;
+                if (gizmoTypeOld == gizmoType) return;
+
+                gizmoTypeOld = gizmoType;
+                eAxis = gizmoType == GizmoType.Move;
+                eScal = gizmoType == GizmoType.Scale;
+                eRotate = gizmoType == GizmoType.Rotate;
+            }
+        }
+        public bool IsGizmoDrag => GizmoVisible && IsDrag && SelectedType != 0;
+        public bool GizmoVisible
+        {
+            get => Visible;
+            set
+            {
+                if (value && IsDrag) IsDrag = false;
+                Visible = value;
+            }
+        }
+        public GizmoMode gizmoMode;
+        public event EventHandler GizmoDrag;
+        public enum GizmoType { Rotate, Move, Scale }
+        public enum GizmoMode { Local, World, Global }
+
+        public static CustomGizmo Make(Transform target, float scale = 0.25f, GizmoMode mode = GizmoMode.Local)
+        {
+            var gizmoGo = new GameObject($"[MPS Gizmo {target.gameObject.name}]");
+            gizmoGo.transform.SetParent(target);
+
+            var gizmo = gizmoGo.AddComponent<CustomGizmo>();
+            gizmo.target = target;
+            gizmo.lineRSelectedThick = 0.25f;
+            gizmo.offsetScale = scale;
+            gizmo.gizmoMode = mode;
+            gizmo.CurrentGizmoType = GizmoType.Rotate;
+
+            return gizmo;
+        }
+
+        public override void Update()
+        {
+            BeginUpdate();
+
+            base.Update();
+
+            if (IsGizmoDrag) SetTargetTransform();
+
+            SetTransform();
+
+            EndUpdate();
+        }
+
+        private void BeginUpdate()
+        {
+            Quaternion rotation = transform.rotation;
+            deltaPosition = transform.position - positionOld;
+            deltaRotation = rotation * Quaternion.Inverse(rotationOld);
+            deltaLocalPosition = transform.InverseTransformVector(deltaPosition);
+            deltaLocalRotation = Quaternion.Inverse(rotationOld) * rotation;
+            deltaScale = transform.localScale - scaleOld;
+        }
+
+        private void EndUpdate()
+        {
+            Transform transform = this.transform;
+            positionOld = transform.position;
+            rotationOld = transform.rotation;
+            scaleOld = transform.localScale;
+        }
+
+        private void SetTargetTransform()
+        {
+            bool dragged;
+
+            switch (gizmoMode)
+            {
+                case GizmoMode.Local:
+                    target.position += target.transform.TransformVector(deltaLocalPosition).normalized
+                        * deltaLocalPosition.magnitude;
+                    target.rotation *= deltaLocalRotation;
+                    target.localScale += deltaScale;
+                    dragged = deltaLocalRotation != Quaternion.identity || deltaLocalPosition != Vector3.zero
+                        || deltaScale != Vector3.zero;
+                    break;
+                case GizmoMode.World:
+                case GizmoMode.Global:
+                    target.position += deltaPosition;
+                    target.rotation = deltaRotation * target.rotation;
+                    dragged = deltaRotation != Quaternion.identity || deltaPosition != Vector3.zero;
+                    break;
+                default: throw new ArgumentOutOfRangeException();
+            }
+
+            if (dragged) OnGizmoDrag();
+        }
+
+        private void SetTransform()
+        {
+            Transform transform = this.transform;
+            transform.position = target.position;
+            transform.localScale = Vector3.one;
+            transform.rotation = gizmoMode switch
+            {
+                GizmoMode.Local => target.rotation,
+                GizmoMode.World => Quaternion.identity,
+                GizmoMode.Global => Quaternion.LookRotation(transform.position - camera.transform.position),
+                _ => target.rotation
+            };
+        }
+
+        private void OnGizmoDrag() => GizmoDrag?.Invoke(this, EventArgs.Empty);
+    }
+}

+ 258 - 0
src/MeidoPhotoStudio.Plugin/DragPoint/DragPoint.cs

@@ -0,0 +1,258 @@
+using System;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    using static CustomGizmo;
+    public abstract class DragPoint : MonoBehaviour
+    {
+        private static readonly int layer = (int) Mathf.Log(LayerMask.GetMask("AbsolutFront"), 2);
+        public const float defaultAlpha = 0.75f;
+        private static GameObject dragPointParent;
+        private const float doubleClickSensitivity = 0.3f;
+        private Func<Vector3> position;
+        private Func<Vector3> rotation;
+        private Collider collider;
+        private Renderer renderer;
+        private bool reinitializeDrag;
+        protected bool Transforming => CurrentDragType >= DragType.MoveXZ;
+        protected bool Special => CurrentDragType == DragType.Select || CurrentDragType == DragType.Delete;
+        protected bool Moving => CurrentDragType == DragType.MoveXZ || CurrentDragType == DragType.MoveY;
+        protected bool Rotating => CurrentDragType >= DragType.RotLocalXZ && CurrentDragType <= DragType.RotLocalY;
+        protected bool Scaling => CurrentDragType == DragType.Scale;
+        protected bool Selecting => CurrentDragType == DragType.Select;
+        protected bool Deleting => CurrentDragType == DragType.Delete;
+        private Vector3 startMousePosition;
+        protected static Camera camera = GameMain.Instance.MainCamera.camera;
+        public enum DragType
+        {
+            None, Ignore, Select, Delete,
+            MoveXZ, MoveY,
+            RotLocalXZ, RotY, RotLocalY,
+            Scale
+        }
+        public Transform MyObject { get; protected set; }
+        public GameObject MyGameObject => MyObject.gameObject;
+        private float startDoubleClick;
+        private Vector3 screenPoint;
+        private Vector3 startOffset;
+        private Vector3 newOffset;
+        public static Material dragPointMaterial = new Material(Shader.Find("CM3D2/Trans_AbsoluteFront"));
+        public static readonly Color defaultColour = new Color(0f, 0f, 0f, 0.4f);
+        public Vector3 OriginalScale { get; private set; }
+        private Vector3 baseScale;
+        public Vector3 BaseScale
+        {
+            get => baseScale;
+            protected set
+            {
+                baseScale = value;
+                transform.localScale = BaseScale * DragPointScale;
+            }
+        }
+        private float dragPointScale = 1f;
+        public float DragPointScale
+        {
+            get => dragPointScale;
+            set
+            {
+                dragPointScale = value;
+                transform.localScale = BaseScale * dragPointScale;
+            }
+        }
+        public GameObject GizmoGo { get; protected set; }
+        public CustomGizmo Gizmo { get; protected set; }
+        private DragType oldDragType;
+        private DragType currentDragType;
+        protected DragType CurrentDragType
+        {
+            get => currentDragType;
+            set
+            {
+                if (value != oldDragType)
+                {
+                    currentDragType = value;
+                    reinitializeDrag = true;
+                    oldDragType = currentDragType;
+                    ApplyDragType();
+                }
+            }
+        }
+        private bool dragPointEnabled = true;
+        public bool DragPointEnabled
+        {
+            get => dragPointEnabled;
+            set
+            {
+                if (dragPointEnabled == value) return;
+                dragPointEnabled = value;
+                ApplyDragType();
+            }
+        }
+        private bool gizmoEnabled = true;
+        public bool GizmoEnabled
+        {
+            get => GizmoGo != null && gizmoEnabled;
+            set
+            {
+                if (GizmoGo == null || (gizmoEnabled == value)) return;
+                gizmoEnabled = value;
+                ApplyDragType();
+            }
+        }
+
+        static DragPoint()
+        {
+            InputManager.Register(MpsKey.DragSelect, KeyCode.A, "Select handle mode");
+            InputManager.Register(MpsKey.DragDelete, KeyCode.D, "Delete handle mode");
+            InputManager.Register(MpsKey.DragMove, KeyCode.Z, "Move handle mode");
+            InputManager.Register(MpsKey.DragRotate, KeyCode.X, "Rotate handle mode");
+            InputManager.Register(MpsKey.DragScale, KeyCode.C, "Scale handle mode");
+            InputManager.Register(MpsKey.DragFinger, KeyCode.Space, "Show finger handles");
+        }
+
+        private void Awake()
+        {
+            BaseScale = OriginalScale = transform.localScale;
+            collider = GetComponent<Collider>();
+            renderer = GetComponent<Renderer>();
+            ApplyDragType();
+        }
+
+        private static GameObject DragPointParent()
+        {
+            return dragPointParent ? dragPointParent : (dragPointParent = new GameObject("[MPS DragPoint Parent]"));
+        }
+
+        public static T Make<T>(PrimitiveType primitiveType, Vector3 scale) where T : DragPoint
+        {
+            GameObject dragPointGo = GameObject.CreatePrimitive(primitiveType);
+            dragPointGo.transform.SetParent(DragPointParent().transform, false);
+            dragPointGo.transform.localScale = scale;
+            dragPointGo.layer = 8;
+
+            T dragPoint = dragPointGo.AddComponent<T>();
+            dragPoint.renderer.material = dragPointMaterial;
+            dragPoint.renderer.material.color = defaultColour;
+
+            return dragPoint;
+        }
+
+        public virtual void Initialize(Func<Vector3> position, Func<Vector3> rotation)
+        {
+            this.position = position;
+            this.rotation = rotation;
+        }
+
+        public virtual void Set(Transform myObject)
+        {
+            MyObject = myObject;
+            gameObject.name = $"[MPS DragPoint: {MyObject.name}]";
+        }
+
+        public virtual void AddGizmo(float scale = 0.25f, GizmoMode mode = GizmoMode.Local)
+        {
+            Gizmo = CustomGizmo.Make(MyObject, scale, mode);
+            GizmoGo = Gizmo.gameObject;
+            Gizmo.GizmoVisible = false;
+            ApplyDragType();
+        }
+
+        protected virtual void ApplyDragType() { }
+
+        public void ApplyProperties(bool active = false, bool visible = false, bool gizmo = false)
+        {
+            collider.enabled = active;
+            renderer.enabled = visible;
+            if (Gizmo) Gizmo.GizmoVisible = gizmo;
+        }
+
+        protected void ApplyColour(Color colour) => renderer.material.color = colour;
+
+        protected void ApplyColour(float r, float g, float b, float a = defaultAlpha)
+        {
+            ApplyColour(new Color(r, g, b, a));
+        }
+
+        protected Vector3 MouseDelta() => Utility.MousePosition - startMousePosition;
+
+        protected bool OtherDragType()
+        {
+            return InputManager.GetKey(MpsKey.DragSelect) || InputManager.GetKey(MpsKey.DragDelete)
+                || InputManager.GetKey(MpsKey.DragMove) || InputManager.GetKey(MpsKey.DragRotate)
+                || InputManager.GetKey(MpsKey.DragScale) || InputManager.GetKey(MpsKey.DragFinger);
+        }
+
+        protected Vector3 CursorPosition()
+        {
+            Vector3 mousePosition = Utility.MousePosition;
+            return camera.ScreenToWorldPoint(new Vector3(mousePosition.x, mousePosition.y, screenPoint.z))
+                + startOffset - newOffset;
+        }
+
+        protected virtual void Update()
+        {
+            transform.position = position();
+            transform.eulerAngles = rotation();
+
+            UpdateDragType();
+        }
+
+        protected virtual void OnMouseDown()
+        {
+            screenPoint = camera.WorldToScreenPoint(transform.position);
+            startMousePosition = Utility.MousePosition;
+            startOffset = transform.position - camera.ScreenToWorldPoint(
+                new Vector3(startMousePosition.x, startMousePosition.y, screenPoint.z)
+            );
+            newOffset = transform.position - MyObject.position;
+        }
+
+        protected virtual void OnMouseDrag()
+        {
+            if (reinitializeDrag)
+            {
+                reinitializeDrag = false;
+                OnMouseDown();
+            }
+
+            if (collider.enabled && startMousePosition != Utility.MousePosition) Drag();
+        }
+
+        protected abstract void UpdateDragType();
+        protected abstract void Drag();
+
+        protected virtual void OnMouseUp()
+        {
+            if ((Time.time - startDoubleClick) < doubleClickSensitivity)
+            {
+                startDoubleClick = -1f;
+                OnDoubleClick();
+            }
+            else
+            {
+                startDoubleClick = Time.time;
+            }
+        }
+
+        protected virtual void OnDoubleClick() { }
+
+        private void OnEnable()
+        {
+            if (position != null)
+            {
+                transform.position = position();
+                transform.eulerAngles = rotation();
+            }
+            if (GizmoGo) GizmoGo.SetActive(true);
+            ApplyDragType();
+        }
+
+        private void OnDisable()
+        {
+            if (GizmoGo) GizmoGo.SetActive(false);
+        }
+
+        protected virtual void OnDestroy() => Destroy(GizmoGo);
+    }
+}

+ 214 - 0
src/MeidoPhotoStudio.Plugin/DragPoint/DragPointGeneral.cs

@@ -0,0 +1,214 @@
+using System;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    using static CustomGizmo;
+    using Input = InputManager;
+
+    public abstract class DragPointGeneral : DragPoint
+    {
+        public const float smallCube = 0.5f;
+        private float currentScale;
+        private bool scaling;
+        private Quaternion currentRotation;
+        public Quaternion DefaultRotation { get; set; } = Quaternion.identity;
+        public Vector3 DefaultPosition { get; set; } = Vector3.zero;
+        public Vector3 DefaultScale { get; set; } = Vector3.one;
+        public float ScaleFactor { get; set; } = 1f;
+        public bool ConstantScale { get; set; }
+        public static readonly Color moveColour = new Color(0.2f, 0.5f, 0.95f, defaultAlpha);
+        public static readonly Color rotateColour = new Color(0.2f, 0.75f, 0.3f, defaultAlpha);
+        public static readonly Color scaleColour = new Color(0.8f, 0.7f, 0.3f, defaultAlpha);
+        public static readonly Color selectColour = new Color(0.9f, 0.5f, 1f, defaultAlpha);
+        public static readonly Color deleteColour = new Color(1f, 0.1f, 0.1f, defaultAlpha);
+        public event EventHandler Move;
+        public event EventHandler Rotate;
+        public event EventHandler Scale;
+        public event EventHandler EndScale;
+        public event EventHandler Delete;
+        public event EventHandler Select;
+
+        public override void AddGizmo(float scale = 0.35f, GizmoMode mode = GizmoMode.Local)
+        {
+            base.AddGizmo(scale, mode);
+            Gizmo.GizmoDrag += (s, a) =>
+            {
+                if (Gizmo.CurrentGizmoType == GizmoType.Rotate) OnRotate();
+            };
+        }
+
+        protected virtual void ApplyColours()
+        {
+            Color colour = moveColour;
+            if (Rotating) colour = rotateColour;
+            else if (Scaling) colour = scaleColour;
+            else if (Selecting) colour = selectColour;
+            else if (Deleting) colour = deleteColour;
+            ApplyColour(colour);
+        }
+
+        protected override void Update()
+        {
+            base.Update();
+
+            if (ConstantScale)
+            {
+                float distance = Vector3.Distance(camera.transform.position, transform.position);
+                transform.localScale = Vector3.one * (0.4f * BaseScale.x * DragPointScale * distance);
+            }
+        }
+
+        protected override void UpdateDragType()
+        {
+            bool shift = Input.Shift;
+            if (Input.GetKey(MpsKey.DragSelect))
+            {
+                CurrentDragType = DragType.Select;
+            }
+            else if (Input.GetKey(MpsKey.DragDelete))
+            {
+                CurrentDragType = DragType.Delete;
+            }
+            else if (Input.GetKey(MpsKey.DragMove))
+            {
+                if (Input.Control) CurrentDragType = DragType.MoveY;
+                else CurrentDragType = shift ? DragType.RotY : DragType.MoveXZ;
+            }
+            else if (Input.GetKey(MpsKey.DragRotate))
+            {
+                CurrentDragType = shift ? DragType.RotLocalY : DragType.RotLocalXZ;
+            }
+            else if (Input.GetKey(MpsKey.DragScale))
+            {
+                CurrentDragType = DragType.Scale;
+            }
+            else
+            {
+                CurrentDragType = DragType.None;
+            }
+        }
+
+        protected override void OnMouseDown()
+        {
+            if (Deleting)
+            {
+                OnDelete();
+                return;
+            }
+
+            if (Selecting)
+            {
+                OnSelect();
+                return;
+            }
+
+            base.OnMouseDown();
+
+            currentScale = MyObject.localScale.x;
+            currentRotation = MyObject.rotation;
+        }
+
+        protected override void OnDoubleClick()
+        {
+            if (Scaling)
+            {
+                MyObject.localScale = DefaultScale;
+                OnScale();
+                OnEndScale();
+            }
+
+            if (Rotating)
+            {
+                ResetRotation();
+                OnRotate();
+            }
+
+            if (Moving)
+            {
+                ResetPosition();
+                OnMove();
+            }
+        }
+
+        protected virtual void ResetPosition() => MyObject.position = DefaultPosition;
+
+        protected virtual void ResetRotation() => MyObject.rotation = DefaultRotation;
+
+        protected override void OnMouseUp()
+        {
+            base.OnMouseUp();
+            if (scaling)
+            {
+                scaling = false;
+                OnScale();
+                OnEndScale();
+            }
+        }
+
+        protected override void Drag()
+        {
+            if (CurrentDragType == DragType.Select || CurrentDragType == DragType.Delete) return;
+
+            Vector3 cursorPosition = CursorPosition();
+            Vector3 mouseDelta = MouseDelta();
+
+            if (CurrentDragType == DragType.MoveXZ)
+            {
+                MyObject.position = new Vector3(cursorPosition.x, MyObject.position.y, cursorPosition.z);
+                OnMove();
+            }
+
+            if (CurrentDragType == DragType.MoveY)
+            {
+                MyObject.position = new Vector3(
+                    MyObject.position.x, cursorPosition.y, MyObject.position.z
+                );
+                OnMove();
+            }
+
+            if (CurrentDragType == DragType.RotY)
+            {
+                MyObject.rotation = currentRotation;
+                MyObject.Rotate(Vector3.up, -mouseDelta.x / 3f, Space.World);
+                OnRotate();
+            }
+
+            if (CurrentDragType == DragType.RotLocalXZ)
+            {
+                MyObject.rotation = currentRotation;
+                Vector3 forward = camera.transform.forward;
+                Vector3 right = camera.transform.right;
+                forward.y = 0f;
+                right.y = 0f;
+                MyObject.Rotate(forward, -mouseDelta.x / 6f, Space.World);
+                MyObject.Rotate(right, mouseDelta.y / 4f, Space.World);
+                OnRotate();
+            }
+
+            if (CurrentDragType == DragType.RotLocalY)
+            {
+                MyObject.rotation = currentRotation;
+                MyObject.Rotate(Vector3.up * -mouseDelta.x / 2.2f);
+                OnRotate();
+            }
+
+            if (CurrentDragType == DragType.Scale)
+            {
+                scaling = true;
+                float scale = currentScale + (mouseDelta.y / 200f * ScaleFactor);
+                if (scale < 0.1f) scale = 0.1f;
+                MyObject.localScale = new Vector3(scale, scale, scale);
+                OnScale();
+            }
+        }
+
+        protected virtual void OnEndScale() => OnEvent(EndScale);
+        protected virtual void OnScale() => OnEvent(Scale);
+        protected virtual void OnMove() => OnEvent(Move);
+        protected virtual void OnRotate() => OnEvent(Rotate);
+        protected virtual void OnSelect() => OnEvent(Select);
+        protected virtual void OnDelete() => OnEvent(Delete);
+        private void OnEvent(EventHandler handler) => handler?.Invoke(this, EventArgs.Empty);
+    }
+}

+ 86 - 0
src/MeidoPhotoStudio.Plugin/DragPoint/DragPointGravity.cs

@@ -0,0 +1,86 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    using static TBody;
+    public class DragPointGravity : DragPointGeneral
+    {
+        private static readonly SlotID[] skirtSlots = { SlotID.skirt, SlotID.onepiece, SlotID.mizugi, SlotID.panz };
+        private static readonly SlotID[] hairSlots = { SlotID.hairF, SlotID.hairR, SlotID.hairS, SlotID.hairT };
+        public GravityTransformControl Control { get; private set; }
+        public bool Valid => Control.isValid;
+        public bool Active => Valid && gameObject.activeSelf;
+
+        public static GravityTransformControl MakeGravityControl(Maid maid, bool skirt = false)
+        {
+            string category = skirt ? "skirt" : "hair";
+
+            Transform bone = maid.body0.GetBone("Bip01");
+            string gravityGoName = $"GravityDatas_{maid.status.guid}_{category}";
+            Transform gravityTransform = maid.gameObject.transform.Find(gravityGoName);
+            if (gravityTransform == null)
+            {
+                GameObject go = new GameObject(gravityGoName);
+                go.transform.SetParent(bone, false);
+                go.transform.SetParent(maid.transform, true);
+                go.transform.localScale = Vector3.one;
+                go.transform.rotation = Quaternion.identity;
+                GameObject go2 = new GameObject(gravityGoName);
+                go2.transform.SetParent(go.transform, false);
+                gravityTransform = go2.transform;
+            }
+            else
+            {
+                gravityTransform = gravityTransform.GetChild(0);
+                GravityTransformControl control = gravityTransform.GetComponent<GravityTransformControl>();
+                if (control != null) GameObject.Destroy(control);
+            }
+
+            GravityTransformControl gravityControl = gravityTransform.gameObject.AddComponent<GravityTransformControl>();
+
+            SlotID[] slots = skirt ? skirtSlots : hairSlots;
+
+            gravityControl.SetTargetSlods(slots);
+            gravityControl.forceRate = 0.1f;
+
+            return gravityControl;
+        }
+
+        public override void Set(Transform myObject)
+        {
+            base.Set(myObject);
+            Control = myObject.GetComponent<GravityTransformControl>();
+            gameObject.SetActive(false);
+        }
+
+        protected override void ResetPosition() => Control.transform.localPosition = DefaultPosition;
+
+        protected override void ApplyDragType()
+        {
+            ApplyProperties(Moving, Moving, false);
+            ApplyColours();
+        }
+
+        protected override void OnDestroy()
+        {
+            if (Control.isValid)
+            {
+                Control.transform.localPosition = Vector3.zero;
+                Control.Update();
+            }
+            GameObject.Destroy(Control.transform.parent.gameObject);
+            base.OnDestroy();
+        }
+
+        private void OnDisable() => Control.isEnabled = false;
+
+        private void OnEnable()
+        {
+            if (Control)
+            {
+                Control.isEnabled = true;
+                if (!Control.isEnabled) gameObject.SetActive(false);
+            }
+        }
+    }
+}

+ 267 - 0
src/MeidoPhotoStudio.Plugin/DragPoint/DragPointLight.cs

@@ -0,0 +1,267 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class DragPointLight : DragPointGeneral
+    {
+        public static EnvironmentManager EnvironmentManager { private get; set; }
+        private Light light;
+        public enum MPSLightType
+        {
+            Normal, Spot, Point, Disabled
+        }
+        public enum LightProp
+        {
+            LightRotX, LightRotY, Intensity, ShadowStrength, SpotAngle, Range, Red, Green, Blue
+        }
+
+        public bool IsActiveLight { get; set; }
+        public string Name { get; private set; } = string.Empty;
+        public bool IsMain { get; set; }
+        public MPSLightType SelectedLightType { get; private set; }
+        public LightProperty CurrentLightProperty => LightProperties[(int)SelectedLightType];
+        private readonly LightProperty[] LightProperties = new LightProperty[]
+        {
+            new LightProperty(),
+            new LightProperty(),
+            new LightProperty()
+        };
+        private bool isDisabled;
+        public bool IsDisabled
+        {
+            get => isDisabled;
+            set
+            {
+                isDisabled = value;
+                light.gameObject.SetActive(!isDisabled);
+            }
+        }
+        private bool isColourMode;
+        public bool IsColourMode
+        {
+            get => IsMain && isColourMode && SelectedLightType == MPSLightType.Normal;
+            set
+            {
+                if (!IsMain) return;
+                light.color = value ? Color.white : LightColour;
+                camera.backgroundColor = value ? LightColour : Color.black;
+                isColourMode = value;
+                LightColour = isColourMode ? camera.backgroundColor : light.color;
+                EnvironmentManager.BGVisible = !IsColourMode;
+            }
+        }
+        public Quaternion Rotation
+        {
+            get => CurrentLightProperty.Rotation;
+            set => light.transform.rotation = CurrentLightProperty.Rotation = value;
+        }
+        public float Intensity
+        {
+            get => CurrentLightProperty.Intensity;
+            set => light.intensity = CurrentLightProperty.Intensity = value;
+        }
+        public float Range
+        {
+            get => CurrentLightProperty.Range;
+            set => light.range = CurrentLightProperty.Range = value;
+        }
+        public float SpotAngle
+        {
+            get => CurrentLightProperty.SpotAngle;
+            set
+            {
+                light.spotAngle = CurrentLightProperty.SpotAngle = value;
+                light.transform.localScale = Vector3.one * value;
+            }
+        }
+        public float ShadowStrength
+        {
+            get => CurrentLightProperty.ShadowStrength;
+            set => light.shadowStrength = CurrentLightProperty.ShadowStrength = value;
+        }
+        public float LightColorRed
+        {
+            get => IsColourMode ? camera.backgroundColor.r : CurrentLightProperty.LightColour.r;
+            set
+            {
+                Color color = IsColourMode ? camera.backgroundColor : light.color;
+                LightColour = new Color(value, color.g, color.b);
+            }
+        }
+        public float LightColorGreen
+        {
+            get => IsColourMode ? camera.backgroundColor.g : CurrentLightProperty.LightColour.r;
+            set
+            {
+                Color color = IsColourMode ? camera.backgroundColor : light.color;
+                LightColour = new Color(color.r, value, color.b);
+            }
+        }
+        public float LightColorBlue
+        {
+            get => IsColourMode ? camera.backgroundColor.b : CurrentLightProperty.LightColour.r;
+            set
+            {
+                Color color = IsColourMode ? camera.backgroundColor : light.color;
+                LightColour = new Color(color.r, color.g, value);
+            }
+        }
+        public Color LightColour
+        {
+            get => IsColourMode ? camera.backgroundColor : CurrentLightProperty.LightColour;
+            set
+            {
+                Color colour = CurrentLightProperty.LightColour = value;
+                if (IsColourMode) camera.backgroundColor = colour;
+                else light.color = colour;
+            }
+        }
+
+        public static void SetLightProperties(Light light, LightProperty prop)
+        {
+            light.transform.rotation = prop.Rotation;
+            light.intensity = prop.Intensity;
+            light.range = prop.Range;
+            light.spotAngle = prop.SpotAngle;
+            light.shadowStrength = prop.ShadowStrength;
+            light.color = prop.LightColour;
+            if (light.type == LightType.Spot) light.transform.localScale = Vector3.one * prop.SpotAngle;
+            else if (light.type == LightType.Point) light.transform.localScale = Vector3.one * prop.Range;
+        }
+
+        public override void Set(Transform myObject)
+        {
+            base.Set(myObject);
+            light = myObject.gameObject.GetOrAddComponent<Light>();
+
+            light.transform.position = LightProperty.DefaultPosition;
+            light.transform.rotation = LightProperty.DefaultRotation;
+
+            SetLightType(MPSLightType.Normal);
+            ScaleFactor = 50f;
+            DefaultRotation = LightProperty.DefaultRotation;
+            DefaultPosition = LightProperty.DefaultPosition;
+        }
+
+        protected override void OnDestroy()
+        {
+            if (!IsMain) Destroy(light.gameObject);
+            base.OnDestroy();
+        }
+
+        protected override void OnRotate()
+        {
+            CurrentLightProperty.Rotation = light.transform.rotation;
+            base.OnRotate();
+        }
+
+        protected override void OnScale()
+        {
+            float value = light.transform.localScale.x;
+            if (SelectedLightType == MPSLightType.Point) Range = value;
+            else if (SelectedLightType == MPSLightType.Spot) SpotAngle = value;
+            base.OnScale();
+        }
+
+        protected override void ApplyDragType()
+        {
+            if (Selecting || Moving) ApplyProperties(true, true, false);
+            else if (SelectedLightType != MPSLightType.Point && Rotating) ApplyProperties(true, true, false);
+            else if (SelectedLightType != MPSLightType.Normal && Scaling) ApplyProperties(true, true, false);
+            else if (!IsMain && Deleting) ApplyProperties(true, true, false);
+            else ApplyProperties(false, false, false);
+
+            ApplyColours();
+        }
+
+        public void SetLightType(MPSLightType type)
+        {
+            LightType lightType = LightType.Directional;
+
+            string name = "normal";
+            SelectedLightType = type;
+
+            if (type == MPSLightType.Spot)
+            {
+                lightType = LightType.Spot;
+                name = "spot";
+            }
+            else if (type == MPSLightType.Point)
+            {
+                lightType = LightType.Point;
+                name = "point";
+            }
+
+            light.type = lightType;
+            Name = IsMain ? "main" : name;
+
+            if (IsMain)
+            {
+                EnvironmentManager.BGVisible = !(IsColourMode && SelectedLightType == MPSLightType.Normal);
+            }
+
+            SetProps();
+            ApplyDragType();
+        }
+
+        public void SetRotation(float x, float y) => Rotation = Quaternion.Euler(x, y, Rotation.eulerAngles.z);
+
+        public void SetProp(LightProp prop, float value)
+        {
+            switch (prop)
+            {
+                case LightProp.Intensity:
+                    Intensity = value;
+                    break;
+                case LightProp.ShadowStrength:
+                    ShadowStrength = value;
+                    break;
+                case LightProp.SpotAngle:
+                    SpotAngle = value;
+                    break;
+                case LightProp.Range:
+                    Range = value;
+                    break;
+                case LightProp.Red:
+                    LightColorRed = value;
+                    break;
+                case LightProp.Green:
+                    LightColorGreen = value;
+                    break;
+                case LightProp.Blue:
+                    LightColorBlue = value;
+                    break;
+            }
+        }
+
+        public void ResetLightProps()
+        {
+            LightProperties[(int)SelectedLightType] = new LightProperty();
+            SetProps();
+        }
+
+        public void ResetLightPosition() => light.transform.position = LightProperty.DefaultPosition;
+
+        private void SetProps()
+        {
+            SetLightProperties(light, CurrentLightProperty);
+            if (IsColourMode)
+            {
+                light.color = Color.white;
+                camera.backgroundColor = CurrentLightProperty.LightColour;
+            }
+        }
+    }
+
+    public class LightProperty
+    {
+        public static readonly Vector3 DefaultPosition = new(0f, 1.9f, 0.4f);
+        public static readonly Quaternion DefaultRotation = Quaternion.Euler(40f, 180f, 0f);
+        public Quaternion Rotation { get; set; } = DefaultRotation;
+        public float Intensity { get; set; } = 0.95f;
+        public float Range { get; set; } = GameMain.Instance.MainLight.GetComponent<Light>().range;
+        public float SpotAngle { get; set; } = 50f;
+        public float ShadowStrength { get; set; } = 0.10f;
+        public Color LightColour { get; set; } = Color.white;
+    }
+}

+ 65 - 0
src/MeidoPhotoStudio.Plugin/DragPoint/DragPointMeido.cs

@@ -0,0 +1,65 @@
+using System;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    using static CustomGizmo;
+    public abstract class DragPointMeido : DragPoint
+    {
+        public static readonly Vector3 boneScale = Vector3.one * 0.04f;
+        protected const int jointUpper = 0;
+        protected const int jointMiddle = 1;
+        protected const int jointLower = 2;
+        protected Meido meido;
+        protected Maid maid;
+        protected IKCtrlData IkCtrlData => meido.Body.IKCtrl.GetIKData("左手");
+        protected bool isPlaying;
+        protected bool isBone;
+        public virtual bool IsBone
+        {
+            get => isBone;
+            set
+            {
+                if (value != isBone)
+                {
+                    isBone = value;
+                    ApplyDragType();
+                }
+            }
+        }
+
+        public virtual void Initialize(Meido meido, Func<Vector3> position, Func<Vector3> rotation)
+        {
+            base.Initialize(position, rotation);
+            this.meido = meido;
+            maid = meido.Maid;
+            isPlaying = !meido.Stop;
+        }
+
+        public override void AddGizmo(float scale = 0.25f, GizmoMode mode = GizmoMode.Local)
+        {
+            base.AddGizmo(scale, mode);
+            Gizmo.GizmoDrag += (s, a) =>
+            {
+                meido.Stop = true;
+                isPlaying = false;
+            };
+        }
+
+        protected override void OnMouseDown()
+        {
+            base.OnMouseDown();
+            isPlaying = !meido.Stop;
+        }
+
+        protected void InitializeIK(TBody.IKCMO iKCmo, Transform upper, Transform middle, Transform lower)
+        {
+            iKCmo.Init(upper, middle, lower, maid.body0);
+        }
+
+        protected void Porc(TBody.IKCMO ikCmo, IKCtrlData ikData, Transform upper, Transform middle, Transform lower)
+        {
+            ikCmo.Porc(upper, middle, lower, CursorPosition(), Vector3.zero, ikData);
+        }
+    }
+}

+ 47 - 0
src/MeidoPhotoStudio.Plugin/DragPoint/DragPointOther.cs

@@ -0,0 +1,47 @@
+using System.Collections.Generic;
+using UnityEngine;
+using UnityEngine.Rendering;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class DragPointBody : DragPointGeneral
+    {
+        public bool IsCube;
+        private bool isIK;
+        public bool IsIK
+        {
+            get => isIK;
+            set
+            {
+                if (isIK != value)
+                {
+                    isIK = value;
+                    ApplyDragType();
+                }
+            }
+        }
+        protected override void ApplyDragType()
+        {
+            bool enabled = !IsIK && (Transforming || Selecting);
+            bool select = IsIK && Selecting;
+            ApplyProperties(enabled || select, IsCube && enabled, false);
+
+            if (IsCube) ApplyColours();
+        }
+    }
+
+    public class DragPointBG : DragPointGeneral
+    {
+        public override void Set(Transform myObject)
+        {
+            base.Set(myObject);
+            DefaultPosition = myObject.position;
+        }
+
+        protected override void ApplyDragType()
+        {
+            ApplyProperties(Transforming, Transforming, Rotating);
+            ApplyColours();
+        }
+    }
+}

+ 113 - 0
src/MeidoPhotoStudio.Plugin/DragPoint/DragPointProp.cs

@@ -0,0 +1,113 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using UnityEngine;
+using UnityEngine.Rendering;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class DragPointProp : DragPointGeneral
+    {
+        private List<Renderer> renderers;
+        public AttachPointInfo AttachPointInfo { get; private set; } = AttachPointInfo.Empty;
+        public string Name => MyGameObject.name;
+        public string assetName = string.Empty;
+        public PropInfo Info { get; set; }
+
+        public bool ShadowCasting
+        {
+            get => renderers.Count != 0 && renderers.Any(r => r.shadowCastingMode == ShadowCastingMode.On);
+            set
+            {
+                foreach (var renderer in renderers)
+                    renderer.shadowCastingMode = value ? ShadowCastingMode.On : ShadowCastingMode.Off;
+            }
+        }
+
+        public override void Set(Transform myObject)
+        {
+            base.Set(myObject);
+            DefaultRotation = MyObject.rotation;
+            DefaultPosition = MyObject.position;
+            DefaultScale = MyObject.localScale;
+            renderers = new List<Renderer>(MyObject.GetComponentsInChildren<Renderer>());
+        }
+
+        public void AttachTo(Meido meido, AttachPoint point, bool keepWorldPosition = true)
+        {
+            var attachPoint = meido?.IKManager.GetAttachPointTransform(point);
+
+            AttachPointInfo = meido == null ? AttachPointInfo.Empty : new AttachPointInfo(point, meido);
+
+            var position = MyObject.position;
+            var rotation = MyObject.rotation;
+            var scale = MyObject.localScale;
+
+            MyObject.transform.SetParent(attachPoint, keepWorldPosition);
+
+            if (keepWorldPosition)
+            {
+                MyObject.position = position;
+                MyObject.rotation = rotation;
+            }
+            else
+            {
+                MyObject.localPosition = Vector3.zero;
+                MyObject.rotation = Quaternion.identity;
+            }
+
+            MyObject.localScale = scale;
+
+            if (attachPoint == null) Utility.FixGameObjectScale(MyGameObject);
+        }
+
+        public void DetachFrom(bool keepWorldPosition = true) => AttachTo(null, AttachPoint.None, keepWorldPosition);
+
+        public void DetachTemporary()
+        {
+            MyObject.transform.SetParent(null, true);
+            Utility.FixGameObjectScale(MyGameObject);
+        }
+
+        protected override void ApplyDragType()
+        {
+            var active = DragPointEnabled && Transforming || Special;
+            ApplyProperties(active, active, GizmoEnabled && Rotating);
+            ApplyColours();
+        }
+
+        protected override void OnDestroy()
+        {
+            Destroy(MyGameObject);
+            base.OnDestroy();
+        }
+    }
+
+    public class PropInfo
+    {
+        public enum PropType { Mod, MyRoom, Bg, Odogu }
+
+        public PropType Type { get; }
+        public string IconFile { get; init; }
+        public string Filename { get; init; }
+        public string SubFilename { get; init; }
+        public int MyRoomID { get; init; }
+
+        public PropInfo(PropType type) => Type = type;
+
+        public static PropInfo FromModItem(ModItem modItem) => new(PropType.Mod)
+        {
+            Filename = modItem.IsOfficialMod ? Path.GetFileName(modItem.MenuFile) : modItem.MenuFile,
+            SubFilename = modItem.BaseMenuFile
+        };
+
+        public static PropInfo FromMyRoom(MyRoomItem myRoomItem) => new(PropType.MyRoom)
+        {
+            MyRoomID = myRoomItem.ID, Filename = myRoomItem.PrefabName
+        };
+
+        public static PropInfo FromBg(string name) => new(PropType.Bg) { Filename = name };
+
+        public static PropInfo FromGameProp(string name) => new(PropType.Odogu) { Filename = name };
+    }
+}

+ 12 - 0
src/MeidoPhotoStudio.Plugin/GUI/Controls/BaseControl.cs

@@ -0,0 +1,12 @@
+using System;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public abstract class BaseControl
+    {
+        public event EventHandler ControlEvent;
+        public virtual void Draw(params GUILayoutOption[] layoutOptions) { }
+        public virtual void OnControlEvent(EventArgs args) => ControlEvent?.Invoke(this, args);
+    }
+}

+ 21 - 0
src/MeidoPhotoStudio.Plugin/GUI/Controls/Button.cs

@@ -0,0 +1,21 @@
+using System;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class Button : BaseControl
+    {
+        public string Label { get; set; }
+        public Button(string label) => Label = label;
+        public void Draw(GUIStyle buttonStyle, params GUILayoutOption[] layoutOptions)
+        {
+            if (GUILayout.Button(Label, buttonStyle, layoutOptions)) OnControlEvent(EventArgs.Empty);
+        }
+
+        public override void Draw(params GUILayoutOption[] layoutOptions)
+        {
+            GUIStyle buttonStyle = new GUIStyle(GUI.skin.button);
+            Draw(buttonStyle, layoutOptions);
+        }
+    }
+}

+ 47 - 0
src/MeidoPhotoStudio.Plugin/GUI/Controls/ComboBox.cs

@@ -0,0 +1,47 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class ComboBox : BaseControl
+    {
+        private readonly TextField textField = new TextField();
+        public Dropdown BaseDropDown { get; }
+        public string Value
+        {
+            get => textField.Value;
+            set => textField.Value = value;
+        }
+
+        public ComboBox(string[] itemList)
+        {
+            BaseDropDown = new Dropdown("▾", itemList);
+            BaseDropDown.SelectionChange += (s, a) => textField.Value = BaseDropDown.SelectedItem;
+            Value = itemList[0];
+        }
+
+        public void SetDropdownItems(string[] itemList)
+        {
+            string oldValue = Value;
+            BaseDropDown.SetDropdownItems(itemList);
+            Value = oldValue;
+        }
+
+        public void SetDropdownItem(int index, string newItem) => BaseDropDown.SetDropdownItem(index, newItem);
+
+        public void SetDropdownItem(string newItem) => BaseDropDown.SetDropdownItem(newItem);
+
+        public override void Draw(params GUILayoutOption[] layoutOptions)
+        {
+            GUIStyle buttonStyle = new GUIStyle(GUI.skin.button) { alignment = TextAnchor.MiddleCenter };
+            Draw(buttonStyle, layoutOptions);
+        }
+
+        public void Draw(GUIStyle style, params GUILayoutOption[] layoutOptions)
+        {
+            GUILayout.BeginHorizontal();
+            textField.Draw(new GUIStyle(GUI.skin.textField), layoutOptions);
+            BaseDropDown.Draw(style, GUILayout.ExpandWidth(false));
+            GUILayout.EndHorizontal();
+        }
+    }
+}

+ 359 - 0
src/MeidoPhotoStudio.Plugin/GUI/Controls/DropDown.cs

@@ -0,0 +1,359 @@
+using System;
+using UnityEngine;
+namespace MeidoPhotoStudio.Plugin
+{
+    using DropdownSelectArgs = DropdownHelper.DropdownSelectArgs;
+    using DropdownCloseArgs = DropdownHelper.DropdownCloseArgs;
+    public class Dropdown : BaseControl
+    {
+        public event EventHandler SelectionChange;
+        public event EventHandler DropdownOpen;
+        public event EventHandler DropdownClose;
+        private bool clickedYou;
+        private bool showDropdown;
+        private readonly string label;
+        private readonly bool isMenu;
+        public string[] DropdownList { get; private set; }
+        public int DropdownID { get; }
+        private Vector2 scrollPos;
+        public Vector2 ScrollPos => scrollPos;
+        private Rect buttonRect;
+        public Rect ButtonRect
+        {
+            get => buttonRect;
+            private set => buttonRect = value;
+        }
+        private Vector2 elementSize;
+        public Vector2 ElementSize => elementSize;
+        private int selectedItemIndex;
+        public int SelectedItemIndex
+        {
+            get => selectedItemIndex;
+            set
+            {
+                selectedItemIndex = Mathf.Clamp(value, 0, DropdownList.Length - 1);
+                OnDropdownEvent(SelectionChange);
+            }
+        }
+        public string SelectedItem => DropdownList[SelectedItemIndex];
+
+        public Dropdown(string label, string[] itemList, int selectedItemIndex = 0)
+            : this(itemList, selectedItemIndex)
+        {
+            isMenu = true;
+            this.label = label;
+        }
+
+        public Dropdown(string[] itemList, int selectedItemIndex = 0)
+        {
+            DropdownID = DropdownHelper.DropdownID;
+            SetDropdownItems(itemList, selectedItemIndex);
+
+            DropdownHelper.SelectionChange += OnChangeSelection;
+            DropdownHelper.DropdownClose += OnCloseDropdown;
+        }
+
+        // TODO: I don't think this works the way I think it does
+        ~Dropdown()
+        {
+            DropdownHelper.SelectionChange -= OnChangeSelection;
+            DropdownHelper.DropdownClose -= OnCloseDropdown;
+        }
+
+        public void SetDropdownItems(string[] itemList, int selectedItemIndex = -1)
+        {
+            if (selectedItemIndex < 0) selectedItemIndex = SelectedItemIndex;
+            elementSize = Vector2.zero;
+
+            // TODO: Calculate scrollpos position maybe
+            if ((selectedItemIndex != this.selectedItemIndex) || (itemList.Length != DropdownList?.Length))
+            {
+                scrollPos = Vector2.zero;
+            }
+            DropdownList = itemList;
+            SelectedItemIndex = selectedItemIndex;
+        }
+
+        public void SetDropdownItem(int index, string newItem)
+        {
+            if (index < 0 || index >= DropdownList.Length) return;
+
+            Vector2 itemSize = DropdownHelper.CalculateElementSize(newItem);
+
+            if (itemSize.x > ElementSize.x) elementSize = itemSize;
+
+            DropdownList[index] = newItem;
+        }
+
+        public void SetDropdownItem(string newItem)
+        {
+            SetDropdownItem(SelectedItemIndex, newItem);
+        }
+
+        public void Step(int dir)
+        {
+            dir = (int)Mathf.Sign(dir);
+            SelectedItemIndex = Utility.Wrap(SelectedItemIndex + dir, 0, DropdownList.Length);
+        }
+
+        public void Draw(GUIStyle buttonStyle, params GUILayoutOption[] layoutOptions)
+        {
+            Draw(buttonStyle, null, layoutOptions);
+        }
+
+        public void Draw(GUIStyle buttonStyle, GUIStyle dropdownStyle = null, params GUILayoutOption[] layoutOptions)
+        {
+            bool clicked = GUILayout.Button(
+                isMenu ? label : DropdownList[selectedItemIndex], buttonStyle, layoutOptions
+            );
+
+            if (clicked)
+            {
+                showDropdown = !clickedYou;
+                clickedYou = false;
+            }
+
+            if (showDropdown && Event.current.type == EventType.Repaint) InitializeDropdown(dropdownStyle);
+        }
+
+        public override void Draw(params GUILayoutOption[] layoutOptions)
+        {
+            GUIStyle buttonStyle = new GUIStyle(GUI.skin.button) { alignment = TextAnchor.MiddleLeft };
+            Draw(buttonStyle, layoutOptions);
+        }
+
+        private void OnChangeSelection(object sender, DropdownSelectArgs args)
+        {
+            if (args.DropdownID == DropdownID)
+            {
+                SelectedItemIndex = args.SelectedItemIndex;
+            }
+        }
+
+        private void OnCloseDropdown(object sender, DropdownCloseArgs args)
+        {
+            if (args.DropdownID == DropdownID)
+            {
+                scrollPos = args.ScrollPos;
+                clickedYou = args.ClickedYou;
+
+                if (clickedYou) OnDropdownEvent(SelectionChange);
+
+                OnDropdownEvent(DropdownClose);
+            }
+        }
+
+        private void InitializeDropdown(GUIStyle dropdownStyle)
+        {
+            showDropdown = false;
+
+            buttonRect = GUILayoutUtility.GetLastRect();
+            Vector2 rectPos = GUIUtility.GUIToScreenPoint(new Vector2(buttonRect.x, buttonRect.y));
+            buttonRect.x = rectPos.x;
+            buttonRect.y = rectPos.y;
+            if (elementSize == Vector2.zero)
+            {
+                elementSize = DropdownHelper.CalculateElementSize(DropdownList, dropdownStyle);
+            }
+            DropdownHelper.Set(this, dropdownStyle);
+
+            OnDropdownEvent(DropdownOpen);
+        }
+
+        private void OnDropdownEvent(EventHandler handler)
+        {
+            handler?.Invoke(this, EventArgs.Empty);
+        }
+    }
+
+    public static class DropdownHelper
+    {
+        public static event EventHandler<DropdownSelectArgs> SelectionChange;
+        public static event EventHandler<DropdownCloseArgs> DropdownClose;
+        private static int dropdownID = 100;
+        public static int DropdownID => dropdownID++;
+        private static GUIStyle defaultDropdownStyle;
+        public static GUIStyle DefaultDropdownStyle
+        {
+            get
+            {
+                if (!initialized) InitializeStyle();
+                return defaultDropdownStyle;
+            }
+        }
+        private static GUIStyle dropdownStyle;
+        private static GUIStyle windowStyle;
+        private static Rect buttonRect;
+        private static string[] dropdownList;
+        private static Vector2 scrollPos;
+        private static int currentDropdownID;
+        private static int selectedItemIndex;
+        private static bool initialized;
+        public static bool Visible { get; set; }
+        public static bool DropdownOpen { get; private set; }
+        private static bool onScrollBar;
+        public static Rect dropdownWindow;
+        private static Rect dropdownScrollRect;
+        private static Rect dropdownRect;
+
+        public static Vector2 CalculateElementSize(string item, GUIStyle style = null)
+        {
+            if (!initialized) InitializeStyle();
+
+            style ??= DefaultDropdownStyle;
+
+            return style.CalcSize(new GUIContent(item));
+        }
+
+        public static Vector2 CalculateElementSize(string[] list, GUIStyle style = null)
+        {
+            if (!initialized) InitializeStyle();
+
+            style ??= DefaultDropdownStyle;
+
+            GUIContent content = new GUIContent(list[0]);
+            Vector2 calculatedSize = style.CalcSize(content);
+            for (int i = 1; i < list.Length; i++)
+            {
+                content.text = list[i];
+                Vector2 calcSize = style.CalcSize(content);
+                if (calcSize.x > calculatedSize.x) calculatedSize = calcSize;
+            }
+
+            return calculatedSize;
+        }
+
+        public static void Set(Dropdown dropdown, GUIStyle style = null)
+        {
+            dropdownStyle = style ?? DefaultDropdownStyle;
+            currentDropdownID = dropdown.DropdownID;
+            dropdownList = dropdown.DropdownList;
+            scrollPos = dropdown.ScrollPos;
+            selectedItemIndex = dropdown.SelectedItemIndex;
+            scrollPos = dropdown.ScrollPos;
+            buttonRect = dropdown.ButtonRect;
+            Vector2 calculatedSize = dropdown.ElementSize;
+
+            float calculatedListHeight = calculatedSize.y * dropdownList.Length;
+
+            float heightAbove = buttonRect.y;
+            float heightBelow = Screen.height - heightAbove - buttonRect.height;
+
+            float rectWidth = Mathf.Max(calculatedSize.x + 5, buttonRect.width);
+            float rectHeight = Mathf.Min(calculatedListHeight, Mathf.Max(heightAbove, heightBelow));
+
+            if (calculatedListHeight > heightBelow && heightAbove > heightBelow)
+            {
+                dropdownWindow = new Rect(buttonRect.x, buttonRect.y - rectHeight, rectWidth + 18, rectHeight);
+            }
+            else
+            {
+                if (calculatedListHeight > heightBelow) rectHeight -= calculatedSize.y;
+                dropdownWindow = new Rect(buttonRect.x, buttonRect.y + buttonRect.height, rectWidth + 18, rectHeight);
+            }
+
+            dropdownWindow.x = Mathf.Clamp(dropdownWindow.x, 0, Screen.width - rectWidth - 18);
+
+            dropdownScrollRect = new Rect(0, 0, dropdownWindow.width, dropdownWindow.height);
+            dropdownRect = new Rect(0, 0, dropdownWindow.width - 18, calculatedListHeight);
+
+            DropdownOpen = true;
+            Visible = true;
+        }
+
+        public static void HandleDropdown()
+        {
+            dropdownWindow = GUI.Window(Constants.dropdownWindowID, dropdownWindow, GUIFunc, "", windowStyle);
+            if (Input.mouseScrollDelta.y != 0f && Visible && dropdownWindow.Contains(Event.current.mousePosition))
+            {
+                Input.ResetInputAxes();
+            }
+        }
+
+        private static void GUIFunc(int id)
+        {
+            bool clicked = false;
+
+            if (Event.current.type == EventType.MouseUp) clicked = true;
+
+            scrollPos = GUI.BeginScrollView(dropdownScrollRect, scrollPos, dropdownRect);
+            int selection = GUI.SelectionGrid(dropdownRect, selectedItemIndex, dropdownList, 1, dropdownStyle);
+            GUI.EndScrollView();
+
+            bool clickedYou = false;
+            if (Utility.AnyMouseDown())
+            {
+                Vector2 mousePos = GUIUtility.GUIToScreenPoint(Event.current.mousePosition);
+                bool clickedMe = dropdownWindow.Contains(mousePos);
+                onScrollBar = mousePos.x > dropdownWindow.x + dropdownWindow.width - 12f;
+                if (buttonRect.Contains(mousePos)) clickedYou = true;
+                if (!clickedMe) DropdownOpen = false;
+            }
+
+            if (selection != selectedItemIndex || (clicked && !onScrollBar))
+            {
+                SelectionChange?.Invoke(null, new DropdownSelectArgs(currentDropdownID, selection));
+                DropdownOpen = false;
+            }
+
+            if (!DropdownOpen)
+            {
+                Visible = false;
+                DropdownClose?.Invoke(null, new DropdownCloseArgs(currentDropdownID, scrollPos, clickedYou));
+            }
+        }
+
+        private static void InitializeStyle()
+        {
+            defaultDropdownStyle = new GUIStyle(GUI.skin.button)
+            {
+                alignment = TextAnchor.MiddleLeft,
+                margin = new RectOffset(0, 0, 0, 0)
+            };
+            defaultDropdownStyle.padding.top = defaultDropdownStyle.padding.bottom = 2;
+            defaultDropdownStyle.normal.background = Utility.MakeTex(2, 2, new Color(0f, 0f, 0f, 0.5f));
+            Texture2D whiteBackground = new Texture2D(2, 2);
+            defaultDropdownStyle.onHover.background
+                = defaultDropdownStyle.hover.background
+                = defaultDropdownStyle.onNormal.background
+                = whiteBackground;
+            defaultDropdownStyle.onHover.textColor
+                = defaultDropdownStyle.onNormal.textColor
+                = defaultDropdownStyle.hover.textColor
+                = Color.black;
+
+            windowStyle = new GUIStyle(GUI.skin.box)
+            {
+                padding = new RectOffset(0, 0, 0, 0),
+                alignment = TextAnchor.UpperRight
+            };
+            initialized = true;
+        }
+
+        public class DropdownEventArgs : EventArgs
+        {
+            public int DropdownID { get; }
+            public DropdownEventArgs(int dropdownID) => DropdownID = dropdownID;
+        }
+
+        public class DropdownSelectArgs : DropdownEventArgs
+        {
+            public int SelectedItemIndex { get; }
+            public DropdownSelectArgs(int dropdownID, int selection) : base(dropdownID)
+            {
+                SelectedItemIndex = selection;
+            }
+        }
+
+        public class DropdownCloseArgs : DropdownEventArgs
+        {
+            public Vector2 ScrollPos { get; }
+            public bool ClickedYou { get; }
+            public DropdownCloseArgs(int dropdownID, Vector2 scrollPos, bool clickedYou = false) : base(dropdownID)
+            {
+                ScrollPos = scrollPos;
+                ClickedYou = clickedYou;
+            }
+        }
+    }
+}

+ 56 - 0
src/MeidoPhotoStudio.Plugin/GUI/Controls/KeyRebindButton.cs

@@ -0,0 +1,56 @@
+using System;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class KeyRebindButton : BaseControl
+    {
+        private readonly Button button;
+        private bool listening;
+        private KeyCode keyCode;
+        public KeyCode KeyCode
+        {
+            get => keyCode;
+            set
+            {
+                keyCode = value;
+                button.Label = keyCode.ToString();
+            }
+        }
+        public KeyRebindButton(KeyCode code)
+        {
+            button = new Button(code.ToString());
+            button.ControlEvent += (s, a) => StartListening();
+        }
+
+        public void Draw(GUIStyle buttonStyle, params GUILayoutOption[] layoutOptions)
+        {
+            GUI.enabled = !listening && !InputManager.Listening;
+            button.Draw(buttonStyle, layoutOptions);
+            GUI.enabled = true;
+        }
+
+        public override void Draw(params GUILayoutOption[] layoutOptions)
+        {
+            GUIStyle buttonStyle = new GUIStyle(GUI.skin.button);
+            Draw(buttonStyle, layoutOptions);
+        }
+
+        private void StartListening()
+        {
+            listening = true;
+            button.Label = string.Empty;
+            InputManager.StartListening();
+            InputManager.KeyChange += KeyChange;
+        }
+
+        private void KeyChange(object sender, EventArgs args)
+        {
+            listening = false;
+            if (InputManager.CurrentKeyCode != KeyCode.Escape) KeyCode = InputManager.CurrentKeyCode;
+            else KeyCode = KeyCode;
+            InputManager.KeyChange -= KeyChange;
+            OnControlEvent(EventArgs.Empty);
+        }
+    }
+}

+ 39 - 0
src/MeidoPhotoStudio.Plugin/GUI/Controls/Modal.cs

@@ -0,0 +1,39 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public static class Modal
+    {
+        private static BaseWindow currentModal;
+        public static bool Visible
+        {
+            get => currentModal?.Visible ?? false;
+            set
+            {
+                if (currentModal == null) return;
+                currentModal.Visible = value;
+            }
+        }
+
+        public static void Show(BaseWindow modalWindow)
+        {
+            if (currentModal != null) Close();
+            currentModal = modalWindow;
+            Visible = true;
+        }
+
+        public static void Close()
+        {
+            Visible = false;
+            currentModal = null;
+        }
+
+        public static void Draw()
+        {
+            GUIStyle windowStyle = new GUIStyle(GUI.skin.box);
+            currentModal.WindowRect = GUI.ModalWindow(
+                currentModal.windowID, currentModal.WindowRect, currentModal.GUIFunc, "", windowStyle
+            );
+        }
+    }
+}

+ 103 - 0
src/MeidoPhotoStudio.Plugin/GUI/Controls/SelectionGrid.cs

@@ -0,0 +1,103 @@
+using System;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class SelectionGrid : BaseControl
+    {
+        private SimpleToggle[] toggles;
+        private int selectedItemIndex;
+        public int SelectedItemIndex
+        {
+            get => selectedItemIndex;
+            set
+            {
+                selectedItemIndex = Mathf.Clamp(value, 0, toggles.Length - 1);
+                foreach (SimpleToggle toggle in toggles)
+                {
+                    toggle.value = toggle.toggleIndex == selectedItemIndex;
+                }
+                OnControlEvent(EventArgs.Empty);
+            }
+        }
+
+        public SelectionGrid(string[] items, int selected = 0)
+        {
+            selectedItemIndex = Mathf.Clamp(selected, 0, items.Length - 1);
+            toggles = MakeToggles(items);
+        }
+
+        private SimpleToggle[] MakeToggles(string[] items)
+        {
+            SimpleToggle[] toggles = new SimpleToggle[items.Length];
+            for (int i = 0; i < items.Length; i++)
+            {
+                SimpleToggle toggle = new SimpleToggle(items[i], i == SelectedItemIndex) { toggleIndex = i };
+                toggle.ControlEvent += (s, a) =>
+                {
+                    int value = (s as SimpleToggle).toggleIndex;
+                    if (value != SelectedItemIndex) SelectedItemIndex = value;
+                };
+                toggles[i] = toggle;
+            }
+            return toggles;
+        }
+
+        public void SetItems(string[] items, int selectedItemIndex = -1)
+        {
+            if (selectedItemIndex < 0) selectedItemIndex = SelectedItemIndex;
+            if (items.Length != toggles.Length)
+            {
+                toggles = MakeToggles(items);
+            }
+            else
+            {
+                for (int i = 0; i < items.Length; i++)
+                {
+                    string item = items[i];
+                    toggles[i].value = i == SelectedItemIndex;
+                    toggles[i].label = item;
+                }
+            }
+            SelectedItemIndex = Mathf.Clamp(selectedItemIndex, 0, items.Length - 1);
+        }
+
+        public override void Draw(params GUILayoutOption[] layoutOptions)
+        {
+            GUILayout.BeginHorizontal();
+            foreach (SimpleToggle toggle in toggles)
+            {
+                toggle.Draw(layoutOptions);
+            }
+            GUILayout.EndHorizontal();
+        }
+
+        private class SimpleToggle
+        {
+            public int toggleIndex;
+            public bool value;
+            public string label;
+            public event EventHandler ControlEvent;
+
+            public SimpleToggle(string label, bool value = false)
+            {
+                this.label = label;
+                this.value = value;
+            }
+
+            public void Draw(params GUILayoutOption[] layoutOptions)
+            {
+                bool value = GUILayout.Toggle(this.value, label, layoutOptions);
+                if (value != this.value)
+                {
+                    if (!value) this.value = true;
+                    else
+                    {
+                        this.value = value;
+                        ControlEvent?.Invoke(this, EventArgs.Empty);
+                    }
+                }
+            }
+        }
+    }
+}

+ 168 - 0
src/MeidoPhotoStudio.Plugin/GUI/Controls/Slider.cs

@@ -0,0 +1,168 @@
+using System;
+using System.Globalization;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class Slider : BaseControl
+    {
+        private bool hasLabel;
+        private string label;
+        public string Label
+        {
+            get => label;
+            set
+            {
+                label = value;
+                hasLabel = !string.IsNullOrEmpty(label);
+            }
+        }
+
+        private float value;
+
+        public float Value
+        {
+            get => value;
+            set
+            {
+                this.value = Utility.Bound(value, Left, Right);
+                if (hasTextField) textFieldValue = FormatValue(value);
+                OnControlEvent(EventArgs.Empty);
+            }
+        }
+
+        private float left;
+
+        public float Left
+        {
+            get => left;
+            set
+            {
+                left = value;
+                this.value = Utility.Bound(value, left, right);
+            }
+        }
+
+        private float right;
+
+        public float Right
+        {
+            get => right;
+            set
+            {
+                right = value;
+                this.value = Utility.Bound(value, left, right);
+            }
+        }
+        private float defaultValue;
+        public float DefaultValue
+        {
+            get => defaultValue;
+            set => defaultValue = Utility.Bound(value, Left, Right);
+        }
+
+        private string textFieldValue;
+        private bool hasTextField;
+        public bool HasTextField
+        {
+            get => hasTextField;
+            set
+            {
+                hasTextField = value;
+                if (hasTextField) textFieldValue = FormatValue(Value);
+            }
+        }
+        public bool HasReset { get; set; }
+
+        public Slider(string label, float left, float right, float value = 0, float defaultValue = 0)
+        {
+            Label = label;
+            this.left = left;
+            this.right = right;
+            this.value = Utility.Bound(value, left, right);
+            textFieldValue = FormatValue(this.value);
+            DefaultValue = defaultValue;
+        }
+
+        public Slider(string label, SliderProp prop) : this(label, prop.Left, prop.Right, prop.Initial, prop.Default) { }
+
+        public Slider(SliderProp prop) : this(string.Empty, prop.Left, prop.Right, prop.Initial, prop.Default) { }
+
+        public void SetBounds(float left, float right)
+        {
+            this.left = left;
+            this.right = right;
+            value = Utility.Bound(value, left, right);
+        }
+
+        public override void Draw(params GUILayoutOption[] layoutOptions)
+        {
+            var hasUpper = hasLabel || HasTextField || HasReset;
+
+            var tempText = string.Empty;
+
+            if (hasUpper)
+            {
+                GUILayout.BeginVertical(GUILayout.ExpandWidth(false));
+                GUILayout.BeginHorizontal();
+
+                if (hasLabel)
+                {
+                    GUILayout.Label(Label, MpsGui.SliderLabelStyle, GUILayout.ExpandWidth(false));
+                    GUILayout.FlexibleSpace();
+                }
+
+                if (HasTextField)
+                {
+                    tempText = GUILayout.TextField(textFieldValue, MpsGui.SliderTextBoxStyle, GUILayout.Width(60f));
+                }
+
+                if (HasReset && GUILayout.Button("|", MpsGui.SliderResetButtonStyle, GUILayout.Width(15f)))
+                {
+                    Value = DefaultValue;
+                    tempText = textFieldValue = FormatValue(Value);
+                }
+                GUILayout.EndHorizontal();
+            }
+
+            GUIStyle sliderStyle = hasUpper ? MpsGui.SliderStyle : MpsGui.SliderStyleNoLabel;
+
+            var tempValue = GUILayout.HorizontalSlider(
+                Value, Left, Right, sliderStyle, MpsGui.SliderThumbStyle, layoutOptions
+            );
+
+            if (hasUpper) GUILayout.EndVertical();
+
+            if (HasTextField)
+            {
+                if (tempValue != Value) tempText = textFieldValue = FormatValue(tempValue);
+
+                if (tempText != textFieldValue)
+                {
+                    textFieldValue = tempText;
+                    if (float.TryParse(tempText, out var newValue)) tempValue = newValue;
+                }
+            }
+
+            if (tempValue != Value) Value = tempValue;
+        }
+
+        private static string FormatValue(float value) => value.ToString("0.####", CultureInfo.InvariantCulture);
+    }
+
+    public readonly struct SliderProp
+    {
+        public float Left { get; }
+        public float Right { get; }
+        public float Initial { get; }
+        public float Default { get; }
+
+        public SliderProp(float left, float right, float initial = 0f, float @default = 0f)
+        {
+            Left = left;
+            Right = right;
+            Initial = Utility.Bound(initial, left, right);
+            Default = Utility.Bound(@default, left, right);
+        }
+    }
+}

+ 17 - 0
src/MeidoPhotoStudio.Plugin/GUI/Controls/TextArea.cs

@@ -0,0 +1,17 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class TextArea : BaseControl
+    {
+        public string Value { get; set; } = string.Empty;
+        public void Draw(GUIStyle textAreaStyle, params GUILayoutOption[] layoutOptions)
+        {
+            Value = GUILayout.TextArea(Value, textAreaStyle, layoutOptions);
+        }
+        public override void Draw(params GUILayoutOption[] layoutOptions)
+        {
+            Draw(new GUIStyle(GUI.skin.textArea), layoutOptions);
+        }
+    }
+}

+ 24 - 0
src/MeidoPhotoStudio.Plugin/GUI/Controls/TextField.cs

@@ -0,0 +1,24 @@
+using System;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class TextField : BaseControl
+    {
+        private static int textFieldID = 961;
+        private static int ID => ++textFieldID;
+        private readonly string controlName = $"textField{ID}";
+        public string Value { get; set; } = string.Empty;
+        public void Draw(GUIStyle textFieldStyle, params GUILayoutOption[] layoutOptions)
+        {
+            GUI.SetNextControlName(controlName);
+            Value = GUILayout.TextField(Value, textFieldStyle, layoutOptions);
+            if (Event.current.isKey && Event.current.keyCode == KeyCode.Return) OnControlEvent(EventArgs.Empty);
+        }
+
+        public override void Draw(params GUILayoutOption[] layoutOptions)
+        {
+            Draw(new GUIStyle(GUI.skin.textField), layoutOptions);
+        }
+    }
+}

+ 44 - 0
src/MeidoPhotoStudio.Plugin/GUI/Controls/Toggle.cs

@@ -0,0 +1,44 @@
+using System;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class Toggle : BaseControl
+    {
+        private bool value;
+        public bool Value
+        {
+            get => value;
+            set
+            {
+                this.value = value;
+                OnControlEvent(EventArgs.Empty);
+            }
+        }
+
+        public string Label { get; set; }
+
+        public Toggle(string label, bool state = false)
+        {
+            Label = label;
+            value = state;
+        }
+
+        public override void Draw(params GUILayoutOption[] layoutOptions)
+        {
+            Draw(new GUIStyle(GUI.skin.toggle), layoutOptions);
+        }
+
+        public void Draw(GUIStyle toggleStyle, params GUILayoutOption[] layoutOptions)
+        {
+            bool value = GUILayout.Toggle(Value, Label, toggleStyle, layoutOptions);
+            if (value != Value) Value = value;
+        }
+
+        public void Draw(Rect rect)
+        {
+            bool value = GUI.Toggle(rect, Value, Label);
+            if (value != Value) Value = value;
+        }
+    }
+}

+ 188 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindow2Panes/AttachPropPane.cs

@@ -0,0 +1,188 @@
+using System;
+using System.Linq;
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class AttachPropPane : BasePane
+    {
+        private readonly PropManager propManager;
+        private readonly MeidoManager meidoManager;
+        private readonly Dictionary<AttachPoint, Toggle> toggles = new Dictionary<AttachPoint, Toggle>();
+
+        private static readonly Dictionary<AttachPoint, string> toggleTranslation =
+            new Dictionary<AttachPoint, string>()
+            {
+                [AttachPoint.Head] = "head",
+                [AttachPoint.Neck] = "neck",
+                [AttachPoint.UpperArmL] = "upperArmL",
+                [AttachPoint.UpperArmR] = "upperArmR",
+                [AttachPoint.ForearmL] = "forearmL",
+                [AttachPoint.ForearmR] = "forearmR",
+                [AttachPoint.MuneL] = "muneL",
+                [AttachPoint.MuneR] = "muneR",
+                [AttachPoint.HandL] = "handL",
+                [AttachPoint.HandR] = "handR",
+                [AttachPoint.Pelvis] = "pelvis",
+                [AttachPoint.ThighL] = "thighL",
+                [AttachPoint.ThighR] = "thighR",
+                [AttachPoint.CalfL] = "calfL",
+                [AttachPoint.CalfR] = "calfR",
+                [AttachPoint.FootL] = "footL",
+                [AttachPoint.FootR] = "footR",
+                [AttachPoint.Spine1a] = "spine1a",
+                [AttachPoint.Spine1] = "spine1",
+                [AttachPoint.Spine0a] = "spine0a",
+                [AttachPoint.Spine0] = "spine0"
+            };
+
+        private readonly Toggle keepWorldPositionToggle;
+        private readonly Dropdown meidoDropdown;
+        private Toggle activeToggle;
+        private bool meidoDropdownActive;
+        private bool doguDropdownActive;
+        private string header;
+        private bool PaneActive => meidoDropdownActive && doguDropdownActive;
+        private Meido SelectedMeido => meidoManager.ActiveMeidoList[meidoDropdown.SelectedItemIndex];
+        private DragPointProp SelectedProp => propManager.CurrentProp;
+        private bool KeepWoldPosition => keepWorldPositionToggle.Value;
+
+        public AttachPropPane(MeidoManager meidoManager, PropManager propManager)
+        {
+            header = Translation.Get("attachPropPane", "header");
+            this.propManager = propManager;
+            this.meidoManager = meidoManager;
+
+            this.meidoManager.EndCallMeidos += (s, a) => SetMeidoDropdown();
+            this.propManager.PropSelectionChange += (s, a) => UpdateToggles();
+
+            this.propManager.PropListChange += (s, a) =>
+            {
+                doguDropdownActive = this.propManager.PropCount > 0;
+                UpdateToggles();
+            };
+
+            meidoDropdown = new Dropdown(new[] { Translation.Get("systemMessage", "noMaids") });
+            meidoDropdown.SelectionChange += (s, a) => UpdateToggles();
+
+            keepWorldPositionToggle = new Toggle(Translation.Get("attachPropPane", "keepWorldPosition"));
+
+            foreach (AttachPoint attachPoint in Enum.GetValues(typeof(AttachPoint)))
+            {
+                if (attachPoint == AttachPoint.None) continue;
+
+                var point = attachPoint;
+                var toggle = new Toggle(Translation.Get("attachPropPane", toggleTranslation[point]));
+                toggle.ControlEvent += (s, a) => OnToggleChange(point);
+                toggles[point] = toggle;
+            }
+        }
+
+        protected override void ReloadTranslation()
+        {
+            header = Translation.Get("attachPropPane", "header");
+            keepWorldPositionToggle.Label = Translation.Get("attachPropPane", "keepWorldPosition");
+            foreach (AttachPoint attachPoint in Enum.GetValues(typeof(AttachPoint)))
+            {
+                if (attachPoint == AttachPoint.None) continue;
+
+                toggles[attachPoint].Label = Translation.Get("attachPropPane", toggleTranslation[attachPoint]);
+            }
+        }
+
+        public override void Draw()
+        {
+            const float dropdownButtonHeight = 30;
+            const float dropdownButtonWidth = 153f;
+            GUILayoutOption[] dropdownLayoutOptions =
+            {
+                GUILayout.Height(dropdownButtonHeight), GUILayout.Width(dropdownButtonWidth)
+            };
+
+            MpsGui.Header(header);
+            MpsGui.WhiteLine();
+
+            GUI.enabled = PaneActive;
+
+            meidoDropdown.Draw(dropdownLayoutOptions);
+
+            keepWorldPositionToggle.Draw();
+
+            DrawToggleGroup(AttachPoint.Head, AttachPoint.Neck);
+            DrawToggleGroup(AttachPoint.UpperArmR, AttachPoint.Spine1a, AttachPoint.UpperArmL);
+            DrawToggleGroup(AttachPoint.ForearmR, AttachPoint.Spine1, AttachPoint.ForearmL);
+            DrawToggleGroup(AttachPoint.MuneR, AttachPoint.Spine0a, AttachPoint.MuneL);
+            DrawToggleGroup(AttachPoint.HandR, AttachPoint.Spine0, AttachPoint.HandL);
+            DrawToggleGroup(AttachPoint.ThighR, AttachPoint.Pelvis, AttachPoint.ThighL);
+            DrawToggleGroup(AttachPoint.CalfR, AttachPoint.CalfL);
+            DrawToggleGroup(AttachPoint.FootR, AttachPoint.FootL);
+
+            GUI.enabled = true;
+        }
+
+        private void DrawToggleGroup(params AttachPoint[] attachPoints)
+        {
+            GUILayout.BeginHorizontal();
+            GUILayout.FlexibleSpace();
+            foreach (var point in attachPoints) toggles[point].Draw();
+            GUILayout.FlexibleSpace();
+            GUILayout.EndHorizontal();
+        }
+
+        private void OnToggleChange(AttachPoint point)
+        {
+            if (updating) return;
+
+            var toggle = toggles[point];
+            if (toggle.Value)
+            {
+                if (activeToggle != null)
+                {
+                    updating = true;
+                    activeToggle.Value = false;
+                    updating = false;
+                }
+
+                activeToggle = toggle;
+                SelectedProp.AttachTo(SelectedMeido, point, KeepWoldPosition);
+            }
+            else
+            {
+                SelectedProp.DetachFrom(KeepWoldPosition);
+                activeToggle = null;
+            }
+        }
+
+        private void UpdateToggles()
+        {
+            updating = true;
+            if (activeToggle != null) activeToggle.Value = false;
+            activeToggle = null;
+            updating = false;
+
+            if (!meidoManager.HasActiveMeido || propManager.PropCount == 0) return;
+
+            var info = SelectedProp.AttachPointInfo;
+
+            if (SelectedMeido.Maid.status.guid != info.MaidGuid) return;
+
+            updating = true;
+            var toggle = toggles[info.AttachPoint];
+            toggle.Value = true;
+            activeToggle = toggle;
+            updating = false;
+        }
+
+        private void SetMeidoDropdown()
+        {
+            meidoDropdownActive = meidoManager.HasActiveMeido;
+            string[] dropdownList = meidoManager.ActiveMeidoList.Count == 0
+                ? new[] { Translation.Get("systemMessage", "noMaids") }
+                : meidoManager.ActiveMeidoList.Select(meido => $"{meido.Slot + 1}: {meido.FirstName} {meido.LastName}")
+                    .ToArray();
+
+            meidoDropdown.SetDropdownItems(dropdownList, 0);
+        }
+    }
+}

+ 196 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindow2Panes/ModPropsPane.cs

@@ -0,0 +1,196 @@
+using System.Collections.Generic;
+using System.Linq;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    using static MenuFileUtility;
+    public class ModPropsPane : BasePane
+    {
+        private readonly PropManager propManager;
+        private readonly Dropdown propCategoryDropdown;
+        private readonly Toggle modFilterToggle;
+        private readonly Toggle baseFilterToggle;
+        private Vector2 propListScrollPos;
+        private string SelectedCategory => MenuCategories[propCategoryDropdown.SelectedItemIndex];
+        private List<ModItem> modPropList;
+        private string currentCategory;
+        private bool modItemsReady;
+        private bool shouldDraw;
+        private int categoryIndex;
+        private bool modFilter;
+        private bool baseFilter;
+        private int currentListCount;
+        private readonly bool isModsOnly = PropManager.ModItemsOnly;
+        private enum FilterType
+        {
+            None, Mod, Base
+        }
+
+        public ModPropsPane(PropManager propManager)
+        {
+            this.propManager = propManager;
+
+            modItemsReady = MenuFilesReady || PropManager.ModItemsOnly;
+
+            string[] listItems = Translation.GetArray("clothing", MenuCategories);
+
+            if (!modItemsReady)
+            {
+                listItems[0] = Translation.Get("systemMessage", "initializing");
+
+                MenuFilesReadyChange += (s, a) =>
+                {
+                    modItemsReady = true;
+                    propCategoryDropdown.SetDropdownItems(
+                        Translation.GetArray("clothing", MenuCategories)
+                    );
+                };
+            }
+
+            propCategoryDropdown = new Dropdown(listItems);
+
+            propCategoryDropdown.SelectionChange += (s, a) =>
+            {
+                if (!modItemsReady) return;
+                ChangePropCategory();
+            };
+
+            if (!isModsOnly)
+            {
+                modFilterToggle = new Toggle(Translation.Get("background2Window", "modsToggle"));
+                modFilterToggle.ControlEvent += (s, a) => ChangeFilter(FilterType.Mod);
+
+                baseFilterToggle = new Toggle(Translation.Get("background2Window", "baseToggle"));
+                baseFilterToggle.ControlEvent += (s, a) => ChangeFilter(FilterType.Base);
+            }
+        }
+
+        protected override void ReloadTranslation()
+        {
+            string[] listItems = Translation.GetArray("clothing", MenuCategories);
+
+            if (!modItemsReady) listItems[0] = Translation.Get("systemMessage", "initializing");
+
+            propCategoryDropdown.SetDropdownItems(listItems);
+
+            if (!isModsOnly)
+            {
+                modFilterToggle.Label = Translation.Get("background2Window", "modsToggle");
+                baseFilterToggle.Label = Translation.Get("background2Window", "baseToggle");
+            }
+        }
+
+        public float buttonSize = 54f;
+        public override void Draw()
+        {
+            const float dropdownButtonHeight = 30f;
+            float dropdownButtonWidth = isModsOnly ? 120f : 90f;
+            GUILayoutOption[] dropdownLayoutOptions = new GUILayoutOption[] {
+                GUILayout.Height(dropdownButtonHeight),
+                GUILayout.Width(dropdownButtonWidth)
+            };
+
+            GUILayout.BeginHorizontal();
+
+            if (isModsOnly)
+            {
+                GUILayout.FlexibleSpace();
+                propCategoryDropdown.Draw(dropdownLayoutOptions);
+                GUILayout.FlexibleSpace();
+            }
+            else
+            {
+                GUI.enabled = modItemsReady;
+                propCategoryDropdown.Draw(dropdownLayoutOptions);
+
+                GUI.enabled = shouldDraw;
+                modFilterToggle.Draw();
+                baseFilterToggle.Draw();
+                GUI.enabled = true;
+            }
+
+            GUILayout.EndHorizontal();
+
+            if (shouldDraw)
+            {
+                Rect windowRect = parent.WindowRect;
+                float windowHeight = windowRect.height;
+                float windowWidth = windowRect.width;
+
+                // const float buttonSize = 50f;
+                const float offsetTop = 80f;
+                const int columns = 4;
+                float buttonSize = (windowWidth / columns) - 10f;
+
+                Rect positionRect = new Rect(
+                    5f, offsetTop + dropdownButtonHeight, windowWidth - 10f, windowHeight - 145f
+                );
+                Rect viewRect = new Rect(
+                    0f, 0f, buttonSize * columns, (buttonSize * Mathf.Ceil(currentListCount / (float)columns)) + 5
+                );
+                propListScrollPos = GUI.BeginScrollView(positionRect, propListScrollPos, viewRect);
+
+                int modIndex = 0;
+                foreach (ModItem modItem in modPropList)
+                {
+                    if ((modFilter && !modItem.IsMod) || (baseFilter && modItem.IsMod)) continue;
+
+                    float x = modIndex % columns * buttonSize;
+                    float y = modIndex / columns * buttonSize;
+                    Rect iconRect = new Rect(x, y, buttonSize, buttonSize);
+                    if (GUI.Button(iconRect, "")) propManager.AddModProp(modItem);
+                    GUI.DrawTexture(iconRect, modItem.Icon);
+                    modIndex++;
+                }
+
+                GUI.EndScrollView();
+            }
+        }
+
+        private void ChangeFilter(FilterType filterType)
+        {
+            if (updating) return;
+
+            if (modFilterToggle.Value && baseFilterToggle.Value)
+            {
+                updating = true;
+                modFilterToggle.Value = filterType == FilterType.Mod;
+                baseFilterToggle.Value = filterType == FilterType.Base;
+                updating = false;
+            }
+
+            modFilter = modFilterToggle.Value;
+            baseFilter = baseFilterToggle.Value;
+
+            SetListCount();
+        }
+
+        private void ChangePropCategory()
+        {
+            string category = SelectedCategory;
+
+            if (currentCategory == category) return;
+            currentCategory = category;
+
+            categoryIndex = propCategoryDropdown.SelectedItemIndex;
+
+            shouldDraw = categoryIndex > 0;
+
+            if (!shouldDraw) return;
+
+            propListScrollPos = Vector2.zero;
+
+            modPropList = Constants.GetModPropList(category);
+
+            SetListCount();
+        }
+
+        private void SetListCount()
+        {
+            if (modFilter) currentListCount = modPropList.Count(mod => mod.IsMod);
+            else if (baseFilter) currentListCount = modPropList.Count(mod => !mod.IsMod);
+            else currentListCount = modPropList.Count;
+        }
+    }
+}

+ 93 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindow2Panes/MyRoomPropsPane.cs

@@ -0,0 +1,93 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class MyRoomPropsPane : BasePane
+    {
+        private readonly PropManager propManager;
+        private readonly Dropdown propCategoryDropdown;
+        private Vector2 propListScrollPos;
+        private string SelectedCategory => Constants.MyRoomPropCategories[propCategoryDropdown.SelectedItemIndex];
+        private List<MyRoomItem> myRoomPropList;
+        private string currentCategory;
+
+        public MyRoomPropsPane(PropManager propManager)
+        {
+            this.propManager = propManager;
+
+            propCategoryDropdown = new Dropdown(Translation.GetArray("doguCategories", Constants.MyRoomPropCategories));
+            propCategoryDropdown.SelectionChange += (s, a) => ChangePropCategory(SelectedCategory);
+            ChangePropCategory(SelectedCategory);
+        }
+
+        protected override void ReloadTranslation()
+        {
+            propCategoryDropdown.SetDropdownItems(
+                Translation.GetArray("doguCategories", Constants.MyRoomPropCategories)
+            );
+        }
+
+        public override void Draw()
+        {
+            const float dropdownButtonHeight = 30f;
+            const float dropdownButtonWidth = 120f;
+            GUILayoutOption[] dropdownLayoutOptions = new GUILayoutOption[] {
+                GUILayout.Height(dropdownButtonHeight),
+                GUILayout.Width(dropdownButtonWidth)
+            };
+
+            GUILayout.BeginHorizontal();
+            GUILayout.FlexibleSpace();
+            propCategoryDropdown.Draw(dropdownLayoutOptions);
+            GUILayout.FlexibleSpace();
+            GUILayout.EndHorizontal();
+
+            Rect windowRect = parent.WindowRect;
+
+            float windowHeight = windowRect.height;
+            float windowWidth = windowRect.width;
+
+            const float offsetTop = 80f;
+            const int columns = 3;
+            float buttonSize = (windowWidth / columns) - 10f;
+
+            int listCount = myRoomPropList.Count;
+
+            Rect positionRect = new Rect(
+                5f, offsetTop + dropdownButtonHeight, windowWidth - 10f, windowHeight - 145f
+            );
+            Rect viewRect = new Rect(
+                0f, 0f, buttonSize * columns, (buttonSize * Mathf.Ceil(listCount / (float)columns)) + 5f
+            );
+            propListScrollPos = GUI.BeginScrollView(positionRect, propListScrollPos, viewRect);
+
+            for (int i = 0; i < listCount; i++)
+            {
+                float x = i % columns * buttonSize;
+                float y = i / columns * buttonSize;
+                MyRoomItem myRoomItem = myRoomPropList[i];
+                Rect iconRect = new Rect(x, y, buttonSize, buttonSize);
+                if (GUI.Button(iconRect, "")) propManager.AddMyRoomProp(myRoomItem);
+                GUI.DrawTexture(iconRect, myRoomItem.Icon);
+            }
+
+            GUI.EndScrollView();
+        }
+
+        private void ChangePropCategory(string category)
+        {
+            if (currentCategory == category) return;
+            currentCategory = category;
+            propListScrollPos = Vector2.zero;
+            myRoomPropList = Constants.MyRoomPropDict[category];
+            if (myRoomPropList[0].Icon == null)
+            {
+                foreach (MyRoomItem item in myRoomPropList)
+                {
+                    item.Icon = (Texture2D)MyRoomCustom.PlacementData.GetData(item.ID).GetThumbnail();
+                }
+            }
+        }
+    }
+}

+ 151 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindow2Panes/PropManagerPane.cs

@@ -0,0 +1,151 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class PropManagerPane : BasePane
+    {
+        private readonly PropManager propManager;
+        private readonly Dropdown propDropdown;
+        private readonly Button previousPropButton;
+        private readonly Button nextPropButton;
+        private readonly Toggle dragPointToggle;
+        private readonly Toggle gizmoToggle;
+        private readonly Toggle shadowCastingToggle;
+        private readonly Button deletePropButton;
+        private readonly Button copyPropButton;
+        private string propManagerHeader;
+
+        private int CurrentDoguIndex => propManager.CurrentPropIndex;
+
+        public PropManagerPane(PropManager propManager)
+        {
+            this.propManager = propManager;
+            this.propManager.PropListChange += (s, a) =>
+            {
+                UpdatePropList();
+                UpdateToggles();
+            };
+
+            this.propManager.FromPropSelect += (s, a) =>
+            {
+                updating = true;
+                propDropdown.SelectedItemIndex = CurrentDoguIndex;
+                updating = false;
+                UpdateToggles();
+            };
+
+            propDropdown = new Dropdown(this.propManager.PropNameList);
+            propDropdown.SelectionChange += (s, a) =>
+            {
+                if (updating) return;
+                this.propManager.CurrentPropIndex = propDropdown.SelectedItemIndex;
+                UpdateToggles();
+            };
+
+            previousPropButton = new Button("<");
+            previousPropButton.ControlEvent += (s, a) => propDropdown.Step(-1);
+
+            nextPropButton = new Button(">");
+            nextPropButton.ControlEvent += (s, a) => propDropdown.Step(1);
+
+            dragPointToggle = new Toggle(Translation.Get("propManagerPane", "dragPointToggle"));
+            dragPointToggle.ControlEvent += (s, a) =>
+            {
+                if (updating || this.propManager.PropCount == 0) return;
+                this.propManager.CurrentProp.DragPointEnabled = dragPointToggle.Value;
+            };
+
+            gizmoToggle = new Toggle(Translation.Get("propManagerPane", "gizmoToggle"));
+            gizmoToggle.ControlEvent += (s, a) =>
+            {
+                if (updating || this.propManager.PropCount == 0) return;
+                this.propManager.CurrentProp.GizmoEnabled = gizmoToggle.Value;
+            };
+
+            shadowCastingToggle = new Toggle(Translation.Get("propManagerPane", "shadowCastingToggle"));
+            shadowCastingToggle.ControlEvent += (s, a) =>
+            {
+                if (updating || this.propManager.PropCount == 0) return;
+                this.propManager.CurrentProp.ShadowCasting = shadowCastingToggle.Value;
+            };
+
+            copyPropButton = new Button(Translation.Get("propManagerPane", "copyButton"));
+            copyPropButton.ControlEvent += (s, a) => this.propManager.CopyProp(CurrentDoguIndex);
+
+            deletePropButton = new Button(Translation.Get("propManagerPane", "deleteButton"));
+            deletePropButton.ControlEvent += (s, a) => this.propManager.RemoveProp(CurrentDoguIndex);
+
+            propManagerHeader = Translation.Get("propManagerPane", "header");
+        }
+
+        protected override void ReloadTranslation()
+        {
+            dragPointToggle.Label = Translation.Get("propManagerPane", "dragPointToggle");
+            gizmoToggle.Label = Translation.Get("propManagerPane", "gizmoToggle");
+            shadowCastingToggle.Label = Translation.Get("propManagerPane", "shadowCastingToggle");
+            copyPropButton.Label = Translation.Get("propManagerPane", "copyButton");
+            deletePropButton.Label = Translation.Get("propManagerPane", "deleteButton");
+            propManagerHeader = Translation.Get("propManagerPane", "header");
+        }
+
+        public override void Draw()
+        {
+            const float buttonHeight = 30;
+            GUILayoutOption[] arrowLayoutOptions = {
+                GUILayout.Width(buttonHeight),
+                GUILayout.Height(buttonHeight)
+            };
+
+            const float dropdownButtonWidth = 140f;
+            GUILayoutOption[] dropdownLayoutOptions = new GUILayoutOption[] {
+                GUILayout.Height(buttonHeight),
+                GUILayout.Width(dropdownButtonWidth)
+            };
+
+            MpsGui.Header(propManagerHeader);
+            MpsGui.WhiteLine();
+
+            GUI.enabled = propManager.PropCount > 0;
+
+            GUILayout.BeginHorizontal();
+            propDropdown.Draw(dropdownLayoutOptions);
+            previousPropButton.Draw(arrowLayoutOptions);
+            nextPropButton.Draw(arrowLayoutOptions);
+            GUILayout.EndHorizontal();
+
+            GUILayoutOption noExpandWidth = GUILayout.ExpandWidth(false);
+
+            GUILayout.BeginHorizontal();
+            dragPointToggle.Draw(noExpandWidth);
+            gizmoToggle.Draw(noExpandWidth);
+            copyPropButton.Draw(noExpandWidth);
+            deletePropButton.Draw(noExpandWidth);
+            GUILayout.EndHorizontal();
+
+            GUILayout.BeginHorizontal();
+            shadowCastingToggle.Draw(noExpandWidth);
+            GUILayout.EndHorizontal();
+
+            GUI.enabled = true;
+        }
+
+        private void UpdatePropList()
+        {
+            updating = true;
+            propDropdown.SetDropdownItems(propManager.PropNameList, CurrentDoguIndex);
+            updating = false;
+        }
+
+        private void UpdateToggles()
+        {
+            DragPointProp prop = propManager.CurrentProp;
+            if (prop == null) return;
+
+            updating = true;
+            dragPointToggle.Value = prop.DragPointEnabled;
+            gizmoToggle.Value = prop.GizmoEnabled;
+            shadowCastingToggle.Value = prop.ShadowCasting;
+            updating = false;
+        }
+    }
+}

+ 168 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindow2Panes/PropsPane.cs

@@ -0,0 +1,168 @@
+using UnityEngine;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class PropsPane : BasePane
+    {
+        private readonly PropManager propManager;
+        private string currentCategory;
+        private string SelectedCategory => Constants.DoguCategories[doguCategoryDropdown.SelectedItemIndex];
+        private readonly Dropdown doguCategoryDropdown;
+        private readonly Dropdown doguDropdown;
+        private readonly Button addDoguButton;
+        private readonly Button nextDoguButton;
+        private readonly Button prevDoguButton;
+        private readonly Button nextDoguCategoryButton;
+        private readonly Button prevDoguCategoryButton;
+        private static bool handItemsReady;
+        private bool itemSelectorEnabled = true;
+
+        public PropsPane(PropManager propManager)
+        {
+            this.propManager = propManager;
+
+            handItemsReady = Constants.HandItemsInitialized;
+            if (!handItemsReady) Constants.MenuFilesChange += InitializeHandItems;
+
+            doguCategoryDropdown = new Dropdown(Translation.GetArray("doguCategories", Constants.DoguCategories));
+            doguCategoryDropdown.SelectionChange += (s, a) => ChangeDoguCategory(SelectedCategory);
+
+            doguDropdown = new Dropdown(new[] { string.Empty });
+
+            addDoguButton = new Button("+");
+            addDoguButton.ControlEvent += (s, a) => SpawnObject();
+
+            nextDoguButton = new Button(">");
+            nextDoguButton.ControlEvent += (s, a) => doguDropdown.Step(1);
+
+            prevDoguButton = new Button("<");
+            prevDoguButton.ControlEvent += (s, a) => doguDropdown.Step(-1);
+
+            nextDoguCategoryButton = new Button(">");
+            nextDoguCategoryButton.ControlEvent += (s, a) => doguCategoryDropdown.Step(1);
+
+            prevDoguCategoryButton = new Button("<");
+            prevDoguCategoryButton.ControlEvent += (s, a) => doguCategoryDropdown.Step(-1);
+
+            ChangeDoguCategory(SelectedCategory);
+        }
+
+        protected override void ReloadTranslation()
+        {
+            doguCategoryDropdown.SetDropdownItems(
+                Translation.GetArray("doguCategories", Constants.DoguCategories)
+            );
+
+            string category = SelectedCategory;
+
+            string[] translationArray;
+
+            if (category == Constants.customDoguCategories[Constants.DoguCategory.HandItem] && !handItemsReady)
+            {
+                translationArray = new[] { Translation.Get("systemMessage", "initializing") };
+            }
+            else translationArray = GetTranslations(category);
+
+            doguDropdown.SetDropdownItems(translationArray);
+        }
+
+        public override void Draw()
+        {
+            const float buttonHeight = 30;
+            GUILayoutOption[] arrowLayoutOptions = {
+                GUILayout.Width(buttonHeight),
+                GUILayout.Height(buttonHeight)
+            };
+
+            const float dropdownButtonWidth = 120f;
+            GUILayoutOption[] dropdownLayoutOptions = new GUILayoutOption[] {
+                GUILayout.Height(buttonHeight),
+                GUILayout.Width(dropdownButtonWidth)
+            };
+
+            GUILayout.BeginHorizontal();
+            prevDoguCategoryButton.Draw(arrowLayoutOptions);
+            doguCategoryDropdown.Draw(dropdownLayoutOptions);
+            nextDoguCategoryButton.Draw(arrowLayoutOptions);
+            GUILayout.EndHorizontal();
+
+            GUI.enabled = itemSelectorEnabled;
+            GUILayout.BeginHorizontal();
+            doguDropdown.Draw(dropdownLayoutOptions);
+            prevDoguButton.Draw(arrowLayoutOptions);
+            nextDoguButton.Draw(arrowLayoutOptions);
+            addDoguButton.Draw(arrowLayoutOptions);
+            GUILayout.EndHorizontal();
+            GUI.enabled = true;
+        }
+
+        private void InitializeHandItems(object sender, MenuFilesEventArgs args)
+        {
+            if (args.Type == MenuFilesEventArgs.EventType.HandItems)
+            {
+                handItemsReady = true;
+                string selectedCategory = SelectedCategory;
+                if (selectedCategory == Constants.customDoguCategories[Constants.DoguCategory.HandItem])
+                {
+                    ChangeDoguCategory(selectedCategory, true);
+                }
+            }
+        }
+
+        private void ChangeDoguCategory(string category, bool force = false)
+        {
+            if (category != currentCategory || force)
+            {
+                currentCategory = category;
+
+                string[] translationArray;
+
+                if (category == Constants.customDoguCategories[Constants.DoguCategory.HandItem] && !handItemsReady)
+                {
+                    translationArray = new[] { Translation.Get("systemMessage", "initializing") };
+                    itemSelectorEnabled = false;
+                }
+                else
+                {
+                    translationArray = GetTranslations(category);
+                    itemSelectorEnabled = true;
+                }
+                doguDropdown.SetDropdownItems(translationArray, 0);
+            }
+        }
+
+        private string[] GetTranslations(string category)
+        {
+            IEnumerable<string> itemList = Constants.DoguDict[category];
+            if (category == Constants.customDoguCategories[Constants.DoguCategory.HandItem])
+            {
+                itemList = itemList.Select(item =>
+                {
+                    string handItemAsOdogu = Utility.HandItemToOdogu(item);
+                    return Translation.Has("propNames", handItemAsOdogu) ? handItemAsOdogu : item;
+                });
+            }
+
+            string translationCategory = category == Constants.customDoguCategories[Constants.DoguCategory.BGSmall]
+                ? "bgNames"
+                : "propNames";
+
+            return Translation.GetArray(translationCategory, itemList);
+        }
+
+        private void SpawnObject()
+        {
+            string assetName = Constants.DoguDict[SelectedCategory][doguDropdown.SelectedItemIndex];
+            if (SelectedCategory == Constants.customDoguCategories[Constants.DoguCategory.BGSmall])
+            {
+                propManager.AddBgProp(assetName);
+            }
+            else
+            {
+                propManager.AddGameProp(assetName);
+            }
+        }
+    }
+}

+ 82 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindowPanes/BackgroundSelectorPane.cs

@@ -0,0 +1,82 @@
+using UnityEngine;
+using System.Linq;
+using System.Collections.Generic;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class BackgroundSelectorPane : BasePane
+    {
+        private readonly EnvironmentManager environmentManager;
+        private readonly Dropdown bgDropdown;
+        private readonly Button prevBGButton;
+        private readonly Button nextBGButton;
+
+        public BackgroundSelectorPane(EnvironmentManager environmentManager)
+        {
+            this.environmentManager = environmentManager;
+
+            int theaterIndex = Constants.BGList.FindIndex(bg => bg == EnvironmentManager.defaultBg);
+
+            List<string> bgList = new List<string>(Translation.GetList("bgNames", Constants.BGList));
+            if (Constants.MyRoomCustomBGIndex >= 0)
+            {
+                bgList.AddRange(Constants.MyRoomCustomBGList.Select(kvp => kvp.Value));
+            }
+
+            bgDropdown = new Dropdown(bgList.ToArray(), theaterIndex);
+            bgDropdown.SelectionChange += (s, a) => ChangeBackground();
+
+            prevBGButton = new Button("<");
+            prevBGButton.ControlEvent += (s, a) => bgDropdown.Step(-1);
+
+            nextBGButton = new Button(">");
+            nextBGButton.ControlEvent += (s, a) => bgDropdown.Step(1);
+        }
+
+        protected override void ReloadTranslation()
+        {
+            List<string> bgList = new List<string>(Translation.GetList("bgNames", Constants.BGList));
+            if (Constants.MyRoomCustomBGIndex >= 0)
+            {
+                bgList.AddRange(Constants.MyRoomCustomBGList.Select(kvp => kvp.Value));
+            }
+
+            updating = true;
+            bgDropdown.SetDropdownItems(bgList.ToArray());
+            updating = false;
+        }
+
+        public override void Draw()
+        {
+            const float buttonHeight = 30;
+            GUILayoutOption[] arrowLayoutOptions = {
+                GUILayout.Width(buttonHeight),
+                GUILayout.Height(buttonHeight)
+            };
+
+            const float dropdownButtonWidth = 153f;
+            GUILayoutOption[] dropdownLayoutOptions = new GUILayoutOption[] {
+                GUILayout.Height(buttonHeight),
+                GUILayout.Width(dropdownButtonWidth)
+            };
+
+            GUILayout.BeginHorizontal();
+            prevBGButton.Draw(arrowLayoutOptions);
+            bgDropdown.Draw(dropdownLayoutOptions);
+            nextBGButton.Draw(arrowLayoutOptions);
+            GUILayout.EndHorizontal();
+        }
+
+        private void ChangeBackground()
+        {
+            if (updating) return;
+            int selectedIndex = bgDropdown.SelectedItemIndex;
+            bool isCreative = bgDropdown.SelectedItemIndex >= Constants.MyRoomCustomBGIndex;
+            string bg = isCreative
+                ? Constants.MyRoomCustomBGList[selectedIndex - Constants.MyRoomCustomBGIndex].Key
+                : Constants.BGList[selectedIndex];
+
+            environmentManager.ChangeBackground(bg, isCreative);
+        }
+    }
+}

+ 86 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindowPanes/CameraPane.cs

@@ -0,0 +1,86 @@
+using System.Linq;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class CameraPane : BasePane
+    {
+        private readonly CameraManager cameraManager;
+        private readonly SelectionGrid cameraGrid;
+        private readonly Slider zRotationSlider;
+        private readonly Slider fovSlider;
+        private string header;
+
+        public CameraPane(CameraManager cameraManager)
+        {
+            this.cameraManager = cameraManager;
+            this.cameraManager.CameraChange += (s, a) => UpdatePane();
+
+            Camera camera = CameraUtility.MainCamera.camera;
+            Vector3 eulerAngles = camera.transform.eulerAngles;
+
+            zRotationSlider = new Slider(Translation.Get("cameraPane", "zRotation"), 0f, 360f, eulerAngles.z)
+            {
+                HasReset = true, HasTextField = true
+            };
+            zRotationSlider.ControlEvent += (s, a) =>
+            {
+                if (updating) return;
+                Vector3 newRotation = camera.transform.eulerAngles;
+                newRotation.z = zRotationSlider.Value;
+                camera.transform.rotation = Quaternion.Euler(newRotation);
+            };
+
+            var fieldOfView = camera.fieldOfView;
+            fovSlider = new Slider(Translation.Get("cameraPane", "fov"), 20f, 150f, fieldOfView, fieldOfView)
+            {
+                HasReset = true, HasTextField = true
+            };
+            fovSlider.ControlEvent += (s, a) =>
+            {
+                if (updating) return;
+                camera.fieldOfView = fovSlider.Value;
+            };
+            cameraGrid = new SelectionGrid(
+                Enumerable.Range(1, cameraManager.CameraCount).Select(x => x.ToString()).ToArray()
+            );
+            cameraGrid.ControlEvent += (s, a) =>
+            {
+                if (updating) return;
+                cameraManager.CurrentCameraIndex = cameraGrid.SelectedItemIndex;
+            };
+
+            header = Translation.Get("cameraPane", "header");
+        }
+
+        protected override void ReloadTranslation()
+        {
+            zRotationSlider.Label = Translation.Get("cameraPane", "zRotation");
+            fovSlider.Label = Translation.Get("cameraPane", "fov");
+            header = Translation.Get("cameraPane", "header");
+        }
+
+        public override void Draw()
+        {
+            MpsGui.Header(header);
+            MpsGui.WhiteLine();
+            cameraGrid.Draw();
+            zRotationSlider.Draw();
+            fovSlider.Draw();
+        }
+
+        public override void UpdatePane()
+        {
+            updating = true;
+
+            Camera camera = CameraUtility.MainCamera.camera;
+
+            zRotationSlider.Value = camera.transform.eulerAngles.z;
+            fovSlider.Value = camera.fieldOfView;
+
+            cameraGrid.SelectedItemIndex = cameraManager.CurrentCameraIndex;
+
+            updating = false;
+        }
+    }
+}

+ 74 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindowPanes/DragPointPane.cs

@@ -0,0 +1,74 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class DragPointPane : BasePane
+    {
+        private string header;
+        private readonly Toggle propsCubeToggle;
+        private readonly Toggle smallCubeToggle;
+        private readonly Toggle maidCubeToggle;
+        private readonly Toggle bgCubeToggle;
+        private enum Setting
+        {
+            Prop, Maid, Background, Size
+        }
+
+        public DragPointPane()
+        {
+            header = Translation.Get("movementCube", "header");
+            propsCubeToggle = new Toggle(Translation.Get("movementCube", "props"), PropManager.CubeActive);
+            smallCubeToggle = new Toggle(Translation.Get("movementCube", "small"));
+            maidCubeToggle = new Toggle(Translation.Get("movementCube", "maid"), MeidoDragPointManager.CubeActive);
+            bgCubeToggle = new Toggle(Translation.Get("movementCube", "bg"), EnvironmentManager.CubeActive);
+
+            propsCubeToggle.ControlEvent += (s, a) => ChangeDragPointSetting(Setting.Prop, propsCubeToggle.Value);
+            smallCubeToggle.ControlEvent += (s, a) => ChangeDragPointSetting(Setting.Size, smallCubeToggle.Value);
+            maidCubeToggle.ControlEvent += (s, a) => ChangeDragPointSetting(Setting.Maid, maidCubeToggle.Value);
+            bgCubeToggle.ControlEvent += (s, a) => ChangeDragPointSetting(Setting.Background, bgCubeToggle.Value);
+        }
+
+        protected override void ReloadTranslation()
+        {
+            header = Translation.Get("movementCube", "header");
+            propsCubeToggle.Label = Translation.Get("movementCube", "props");
+            smallCubeToggle.Label = Translation.Get("movementCube", "small");
+            maidCubeToggle.Label = Translation.Get("movementCube", "maid");
+            bgCubeToggle.Label = Translation.Get("movementCube", "bg");
+        }
+
+        public override void Draw()
+        {
+            MpsGui.Header(header);
+            MpsGui.WhiteLine();
+
+            GUILayout.BeginHorizontal();
+            propsCubeToggle.Draw();
+            smallCubeToggle.Draw();
+            maidCubeToggle.Draw();
+            bgCubeToggle.Draw();
+            GUILayout.EndHorizontal();
+        }
+
+        private void ChangeDragPointSetting(Setting setting, bool value)
+        {
+            switch (setting)
+            {
+                case Setting.Prop:
+                    PropManager.CubeActive = value;
+                    break;
+                case Setting.Background:
+                    EnvironmentManager.CubeActive = value;
+                    break;
+                case Setting.Maid:
+                    MeidoDragPointManager.CubeActive = value;
+                    break;
+                case Setting.Size:
+                    MeidoDragPointManager.CubeSmall = value;
+                    EnvironmentManager.CubeSmall = value;
+                    PropManager.CubeSmall = value;
+                    break;
+            }
+        }
+    }
+}

+ 104 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindowPanes/EffectsPanes/BloomPane.cs

@@ -0,0 +1,104 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class BloomPane : EffectPane<BloomEffectManager>
+    {
+        protected override BloomEffectManager EffectManager { get; set; }
+        private readonly Slider intensitySlider;
+        private readonly Slider blurSlider;
+        private readonly Slider redSlider;
+        private readonly Slider greenSlider;
+        private readonly Slider blueSlider;
+        private readonly Toggle hdrToggle;
+
+        public BloomPane(EffectManager effectManager) : base(effectManager)
+        {
+            intensitySlider = new Slider(
+                Translation.Get("effectBloom", "intensity"), 0f, 100f, EffectManager.BloomValue
+            );
+            intensitySlider.ControlEvent += (s, a) =>
+            {
+                if (updating) return;
+                EffectManager.BloomValue = intensitySlider.Value;
+            };
+            blurSlider = new Slider(Translation.Get("effectBloom", "blur"), 0f, 15f, EffectManager.BlurIterations);
+            blurSlider.ControlEvent += (s, a) =>
+            {
+                if (updating) return;
+                EffectManager.BlurIterations = (int)blurSlider.Value;
+            };
+            redSlider = new Slider(
+                Translation.Get("backgroundWindow", "red"), 1f, 0.5f, EffectManager.BloomThresholdColorRed
+            );
+            redSlider.ControlEvent += (s, a) =>
+            {
+                if (updating) return;
+                EffectManager.BloomThresholdColorRed = redSlider.Value;
+            };
+            greenSlider = new Slider(
+                Translation.Get("backgroundWindow", "green"), 1f, 0.5f, EffectManager.BloomThresholdColorGreen
+            );
+            greenSlider.ControlEvent += (s, a) =>
+            {
+                if (updating) return;
+                EffectManager.BloomThresholdColorGreen = greenSlider.Value;
+            };
+            blueSlider = new Slider(
+                Translation.Get("backgroundWindow", "blue"), 1f, 0.5f, EffectManager.BloomThresholdColorBlue
+            );
+            blueSlider.ControlEvent += (s, a) =>
+            {
+                if (updating) return;
+                EffectManager.BloomThresholdColorBlue = blueSlider.Value;
+            };
+            hdrToggle = new Toggle(Translation.Get("effectBloom", "hdrToggle"), EffectManager.BloomHDR);
+            hdrToggle.ControlEvent += (s, a) =>
+            {
+                if (updating) return;
+                EffectManager.BloomHDR = hdrToggle.Value;
+            };
+        }
+
+        protected override void TranslatePane()
+        {
+            intensitySlider.Label = Translation.Get("effectBloom", "intensity");
+            blurSlider.Label = Translation.Get("effectBloom", "blur");
+            redSlider.Label = Translation.Get("backgroundWindow", "red");
+            greenSlider.Label = Translation.Get("backgroundWindow", "green");
+            blueSlider.Label = Translation.Get("backgroundWindow", "blue");
+            hdrToggle.Label = Translation.Get("effectBloom", "hdrToggle");
+        }
+
+        protected override void UpdateControls()
+        {
+            intensitySlider.Value = EffectManager.BloomValue;
+            blurSlider.Value = EffectManager.BlurIterations;
+            redSlider.Value = EffectManager.BloomThresholdColorRed;
+            greenSlider.Value = EffectManager.BloomThresholdColorGreen;
+            blueSlider.Value = EffectManager.BloomThresholdColorBlue;
+            hdrToggle.Value = EffectManager.BloomHDR;
+        }
+
+        protected override void DrawPane()
+        {
+            GUILayoutOption sliderWidth = MpsGui.HalfSlider;
+
+            GUILayout.BeginHorizontal();
+            intensitySlider.Draw(sliderWidth);
+            blurSlider.Draw(sliderWidth);
+            GUILayout.EndHorizontal();
+
+            GUILayout.BeginHorizontal();
+            redSlider.Draw(sliderWidth);
+            greenSlider.Draw(sliderWidth);
+            GUILayout.EndHorizontal();
+
+            GUILayout.BeginHorizontal();
+            blueSlider.Draw(sliderWidth);
+            GUILayout.FlexibleSpace();
+            hdrToggle.Draw(GUILayout.ExpandWidth(false));
+            GUILayout.EndHorizontal();
+        }
+    }
+}

+ 87 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindowPanes/EffectsPanes/DepthOfFieldPane.cs

@@ -0,0 +1,87 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class DepthOfFieldPane : EffectPane<DepthOfFieldEffectManager>
+    {
+        protected override DepthOfFieldEffectManager EffectManager { get; set; }
+        private readonly Slider focalLengthSlider;
+        private readonly Slider focalSizeSlider;
+        private readonly Slider apertureSlider;
+        private readonly Slider blurSlider;
+        private readonly Toggle thicknessToggle;
+
+        public DepthOfFieldPane(EffectManager effectManager) : base(effectManager)
+        {
+            focalLengthSlider = new Slider(
+                Translation.Get("effectDof", "focalLength"), 0f, 10f, EffectManager.FocalLength
+            );
+            focalSizeSlider = new Slider(Translation.Get("effectDof", "focalArea"), 0f, 2f, EffectManager.FocalSize);
+            apertureSlider = new Slider(Translation.Get("effectDof", "aperture"), 0f, 60f, EffectManager.Aperture);
+            blurSlider = new Slider(Translation.Get("effectDof", "blur"), 0f, 10f, EffectManager.MaxBlurSize);
+            thicknessToggle = new Toggle(Translation.Get("effectDof", "thicknessToggle"), EffectManager.VisualizeFocus);
+            focalLengthSlider.ControlEvent += (s, a) =>
+            {
+                if (updating) return;
+                EffectManager.FocalLength = focalLengthSlider.Value;
+            };
+            focalSizeSlider.ControlEvent += (s, a) =>
+            {
+                if (updating) return;
+                EffectManager.FocalSize = focalSizeSlider.Value;
+            };
+            apertureSlider.ControlEvent += (s, a) =>
+            {
+                if (updating) return;
+                EffectManager.Aperture = apertureSlider.Value;
+            };
+            blurSlider.ControlEvent += (s, a) =>
+            {
+                if (updating) return;
+                EffectManager.MaxBlurSize = blurSlider.Value;
+            };
+            thicknessToggle.ControlEvent += (s, a) =>
+            {
+                if (updating) return;
+                EffectManager.VisualizeFocus = thicknessToggle.Value;
+            };
+        }
+
+        protected override void TranslatePane()
+        {
+            focalLengthSlider.Label = Translation.Get("effectDof", "focalLength");
+            focalSizeSlider.Label = Translation.Get("effectDof", "focalArea");
+            apertureSlider.Label = Translation.Get("effectDof", "aperture");
+            blurSlider.Label = Translation.Get("effectDof", "blur");
+            thicknessToggle.Label = Translation.Get("effectDof", "thicknessToggle");
+        }
+
+        protected override void UpdateControls()
+        {
+            focalLengthSlider.Value = EffectManager.FocalLength;
+            focalSizeSlider.Value = EffectManager.FocalSize;
+            apertureSlider.Value = EffectManager.Aperture;
+            blurSlider.Value = EffectManager.MaxBlurSize;
+            thicknessToggle.Value = EffectManager.VisualizeFocus;
+        }
+
+        protected override void DrawPane()
+        {
+            focalLengthSlider.Draw();
+
+            GUILayoutOption sliderWidth = MpsGui.HalfSlider;
+
+            GUILayout.BeginHorizontal();
+            focalSizeSlider.Draw(sliderWidth);
+            apertureSlider.Draw(sliderWidth);
+            GUILayout.EndHorizontal();
+
+            GUILayout.BeginHorizontal();
+            blurSlider.Draw(sliderWidth);
+            GUILayout.FlexibleSpace();
+            thicknessToggle.Draw();
+            GUILayout.EndHorizontal();
+            GUI.enabled = true;
+        }
+    }
+}

+ 74 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindowPanes/EffectsPanes/EffectPane.cs

@@ -0,0 +1,74 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public abstract class EffectPane<T> : BasePane where T : IEffectManager
+    {
+        protected abstract T EffectManager { get; set; }
+        protected readonly Toggle effectToggle;
+        protected readonly Button resetEffectButton;
+        private bool enabled;
+        public override bool Enabled
+        {
+            get => enabled;
+            set
+            {
+                enabled = value;
+                if (updating) return;
+                EffectManager.SetEffectActive(enabled);
+            }
+        }
+
+        protected EffectPane(EffectManager effectManager)
+        {
+            EffectManager = effectManager.Get<T>();
+            resetEffectButton = new Button(Translation.Get("effectsPane", "reset"));
+            resetEffectButton.ControlEvent += (s, a) => ResetEffect();
+            effectToggle = new Toggle(Translation.Get("effectsPane", "onToggle"));
+            effectToggle.ControlEvent += (s, a) => Enabled = effectToggle.Value;
+        }
+
+        protected override void ReloadTranslation()
+        {
+            updating = true;
+            effectToggle.Label = Translation.Get("effectsPane", "onToggle");
+            resetEffectButton.Label = Translation.Get("effectsPane", "reset");
+            TranslatePane();
+            updating = false;
+        }
+
+        protected abstract void TranslatePane();
+
+        public override void UpdatePane()
+        {
+            if (!EffectManager.Ready) return;
+            updating = true;
+            effectToggle.Value = EffectManager.Active;
+            UpdateControls();
+            updating = false;
+        }
+
+        protected abstract void UpdateControls();
+
+        public override void Draw()
+        {
+            GUILayout.BeginHorizontal();
+            effectToggle.Draw();
+            GUILayout.FlexibleSpace();
+            GUI.enabled = Enabled;
+            resetEffectButton.Draw();
+            GUILayout.EndHorizontal();
+            DrawPane();
+            GUI.enabled = true;
+        }
+
+        protected abstract void DrawPane();
+
+        private void ResetEffect()
+        {
+            EffectManager.Deactivate();
+            EffectManager.SetEffectActive(true);
+            UpdatePane();
+        }
+    }
+}

+ 51 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindowPanes/EffectsPanes/EffectsPane.cs

@@ -0,0 +1,51 @@
+using System.Collections.Generic;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class EffectsPane : BasePane
+    {
+        private readonly Dictionary<string, BasePane> effectPanes = new Dictionary<string, BasePane>();
+        private readonly List<string> effectList = new List<string>();
+        private readonly SelectionGrid effectToggles;
+        private BasePane currentEffectPane;
+
+        public BasePane this[string effectUI]
+        {
+            private get => effectPanes[effectUI];
+            set
+            {
+                effectPanes[effectUI] = value;
+                effectList.Add(effectUI);
+                effectToggles.SetItems(Translation.GetArray("effectsPane", effectList), 0);
+            }
+        }
+
+        public EffectsPane()
+        {
+            effectToggles = new SelectionGrid(new[] { "dummy" /* thicc */ });
+            effectToggles.ControlEvent += (s, a) => SetEffectPane(effectList[effectToggles.SelectedItemIndex]);
+        }
+
+        protected override void ReloadTranslation()
+        {
+            effectToggles.SetItems(Translation.GetArray("effectsPane", effectList));
+        }
+
+        private void SetEffectPane(string effectUI)
+        {
+            currentEffectPane = effectPanes[effectUI];
+            currentEffectPane.UpdatePane();
+        }
+
+        public override void UpdatePane() => currentEffectPane.UpdatePane();
+
+        public override void Draw()
+        {
+            MpsGui.Header("Effects");
+            MpsGui.WhiteLine();
+            effectToggles.Draw();
+            MpsGui.BlackLine();
+            currentEffectPane.Draw();
+        }
+    }
+}

+ 115 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindowPanes/EffectsPanes/FogPane.cs

@@ -0,0 +1,115 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class FogPane : EffectPane<FogEffectManager>
+    {
+        protected override FogEffectManager EffectManager { get; set; }
+        private readonly Slider distanceSlider;
+        private readonly Slider densitySlider;
+        private readonly Slider heightScaleSlider;
+        private readonly Slider heightSlider;
+        private readonly Slider redSlider;
+        private readonly Slider greenSlider;
+        private readonly Slider blueSlider;
+
+        public FogPane(EffectManager effectManager) : base(effectManager)
+        {
+            distanceSlider = new Slider(
+                Translation.Get("effectFog", "distance"), 0f, 30f, EffectManager.Distance
+            );
+            densitySlider = new Slider(
+                Translation.Get("effectFog", "density"), 0f, 10f, EffectManager.Density
+            );
+            heightScaleSlider = new Slider(
+                Translation.Get("effectFog", "strength"), -5f, 20f, EffectManager.HeightScale
+            );
+            heightSlider = new Slider(
+                Translation.Get("effectFog", "height"), -10f, 10f, EffectManager.Height
+            );
+            Color initialFogColour = EffectManager.FogColour;
+            redSlider = new Slider(Translation.Get("backgroundWIndow", "red"), 0f, 1f, initialFogColour.r);
+            greenSlider = new Slider(Translation.Get("backgroundWIndow", "green"), 0f, 1f, initialFogColour.g);
+            blueSlider = new Slider(Translation.Get("backgroundWIndow", "blue"), 0f, 1f, initialFogColour.b);
+            distanceSlider.ControlEvent += (s, a) =>
+            {
+                if (updating) return;
+                EffectManager.Distance = distanceSlider.Value;
+            };
+            densitySlider.ControlEvent += (s, a) =>
+            {
+                if (updating) return;
+                EffectManager.Density = densitySlider.Value;
+            };
+            heightScaleSlider.ControlEvent += (s, a) =>
+            {
+                if (updating) return;
+                EffectManager.HeightScale = heightScaleSlider.Value;
+            };
+            heightSlider.ControlEvent += (s, a) =>
+            {
+                if (updating) return;
+                EffectManager.Height = heightSlider.Value;
+            };
+            redSlider.ControlEvent += (s, a) =>
+            {
+                if (updating) return;
+                EffectManager.FogColourRed = redSlider.Value;
+            };
+            greenSlider.ControlEvent += (s, a) =>
+            {
+                if (updating) return;
+                EffectManager.FogColourGreen = greenSlider.Value;
+            };
+            blueSlider.ControlEvent += (s, a) =>
+            {
+                if (updating) return;
+                EffectManager.FogColourBlue = blueSlider.Value;
+            };
+        }
+
+        protected override void TranslatePane()
+        {
+            distanceSlider.Label = Translation.Get("effectFog", "distance");
+            densitySlider.Label = Translation.Get("effectFog", "density");
+            heightScaleSlider.Label = Translation.Get("effectFog", "strength");
+            heightSlider.Label = Translation.Get("effectFog", "height");
+            redSlider.Label = Translation.Get("backgroundWIndow", "red");
+            greenSlider.Label = Translation.Get("backgroundWIndow", "green");
+            blueSlider.Label = Translation.Get("backgroundWIndow", "blue");
+        }
+
+        protected override void UpdateControls()
+        {
+            distanceSlider.Value = EffectManager.Distance;
+            densitySlider.Value = EffectManager.Density;
+            heightScaleSlider.Value = EffectManager.HeightScale;
+            heightSlider.Value = EffectManager.Height;
+            redSlider.Value = EffectManager.FogColourRed;
+            greenSlider.Value = EffectManager.FogColourGreen;
+            blueSlider.Value = EffectManager.FogColourBlue;
+        }
+
+        protected override void DrawPane()
+        {
+            GUILayoutOption sliderWidth = MpsGui.HalfSlider;
+
+            GUILayout.BeginHorizontal();
+            distanceSlider.Draw(sliderWidth);
+            densitySlider.Draw(sliderWidth);
+            GUILayout.EndHorizontal();
+
+            GUILayout.BeginHorizontal();
+            heightScaleSlider.Draw(sliderWidth);
+            heightSlider.Draw(sliderWidth);
+            GUILayout.EndHorizontal();
+
+            GUILayout.BeginHorizontal();
+            redSlider.Draw(sliderWidth);
+            greenSlider.Draw(sliderWidth);
+            GUILayout.EndHorizontal();
+
+            blueSlider.Draw(sliderWidth);
+        }
+    }
+}

+ 64 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindowPanes/EffectsPanes/OtherEffectsPane.cs

@@ -0,0 +1,64 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class OtherEffectsPane : BasePane
+    {
+        private readonly EffectManager effectManager;
+        private readonly SepiaToneEffectManger sepiaToneEffectManger;
+        private readonly BlurEffectManager blurEffectManager;
+        private readonly Toggle sepiaToggle;
+        private readonly Slider blurSlider;
+
+        public OtherEffectsPane(EffectManager effectManager)
+        {
+            this.effectManager = effectManager;
+
+            sepiaToneEffectManger = this.effectManager.Get<SepiaToneEffectManger>();
+            blurEffectManager = this.effectManager.Get<BlurEffectManager>();
+
+            sepiaToggle = new Toggle(Translation.Get("otherEffectsPane", "sepiaToggle"));
+            sepiaToggle.ControlEvent += (s, a) =>
+            {
+                if (updating) return;
+                sepiaToneEffectManger.SetEffectActive(sepiaToggle.Value);
+            };
+
+            blurSlider = new Slider(Translation.Get("otherEffectsPane", "blurSlider"), 0f, 18f);
+            blurSlider.ControlEvent += (s, a) =>
+            {
+                float value = blurSlider.Value;
+                if (!blurEffectManager.Active && value > 0f) blurEffectManager.SetEffectActive(true);
+                else if (blurEffectManager.Active && value == 0f) blurEffectManager.SetEffectActive(false);
+
+                if (blurEffectManager.Active) blurEffectManager.BlurSize = blurSlider.Value;
+            };
+        }
+
+        protected override void ReloadTranslation()
+        {
+            sepiaToggle.Label = Translation.Get("otherEffectsPane", "sepiaToggle");
+            blurSlider.Label = Translation.Get("otherEffectsPane", "blurSlider");
+        }
+
+        public override void Draw()
+        {
+            GUILayout.BeginHorizontal();
+            sepiaToggle.Draw();
+            blurSlider.Draw();
+            GUILayout.EndHorizontal();
+        }
+
+        public override void UpdatePane()
+        {
+            if (sepiaToneEffectManger.Ready)
+            {
+                updating = true;
+                sepiaToggle.Value = sepiaToneEffectManger.Active;
+                updating = false;
+            }
+
+            if (blurEffectManager.Ready) blurSlider.Value = blurEffectManager.BlurSize;
+        }
+    }
+}

+ 72 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindowPanes/EffectsPanes/VignettePane.cs

@@ -0,0 +1,72 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class VignettePane : EffectPane<VignetteEffectManager>
+    {
+        protected override VignetteEffectManager EffectManager { get; set; }
+        private readonly Slider intensitySlider;
+        private readonly Slider blurSlider;
+        private readonly Slider blurSpreadSlider;
+        private readonly Slider aberrationSlider;
+
+        public VignettePane(EffectManager effectManager) : base(effectManager)
+        {
+            intensitySlider = new Slider(Translation.Get("effectVignette", "intensity"), -40f, 70f);
+            intensitySlider.ControlEvent += (s, a) =>
+            {
+                if (updating) return;
+                EffectManager.Intensity = intensitySlider.Value;
+            };
+            blurSlider = new Slider(Translation.Get("effectVignette", "blur"), 0f, 5f);
+            blurSlider.ControlEvent += (s, a) =>
+            {
+                if (updating) return;
+                EffectManager.Blur = blurSlider.Value;
+            };
+            blurSpreadSlider = new Slider(Translation.Get("effectVignette", "blurSpread"), 0f, 40f);
+            blurSpreadSlider.ControlEvent += (s, a) =>
+            {
+                if (updating) return;
+                EffectManager.BlurSpread = blurSpreadSlider.Value;
+            };
+            aberrationSlider = new Slider(Translation.Get("effectVignette", "aberration"), -30f, 30f);
+            aberrationSlider.ControlEvent += (s, a) =>
+            {
+                if (updating) return;
+                EffectManager.ChromaticAberration = aberrationSlider.Value;
+            };
+        }
+
+        protected override void TranslatePane()
+        {
+            intensitySlider.Label = Translation.Get("effectVignette", "intensity");
+            blurSlider.Label = Translation.Get("effectVignette", "blur");
+            blurSpreadSlider.Label = Translation.Get("effectVignette", "blurSpread");
+            aberrationSlider.Label = Translation.Get("effectVignette", "aberration");
+        }
+
+        protected override void UpdateControls()
+        {
+            intensitySlider.Value = EffectManager.Intensity;
+            blurSlider.Value = EffectManager.Blur;
+            blurSpreadSlider.Value = EffectManager.BlurSpread;
+            aberrationSlider.Value = EffectManager.ChromaticAberration;
+        }
+
+        protected override void DrawPane()
+        {
+            GUILayoutOption sliderWidth = MpsGui.HalfSlider;
+
+            GUILayout.BeginHorizontal();
+            intensitySlider.Draw(sliderWidth);
+            blurSlider.Draw(sliderWidth);
+            GUILayout.EndHorizontal();
+
+            GUILayout.BeginHorizontal();
+            blurSpreadSlider.Draw(sliderWidth);
+            aberrationSlider.Draw(sliderWidth);
+            GUILayout.EndHorizontal();
+        }
+    }
+}

+ 295 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/BackgroundWindowPanes/LightsPane.cs

@@ -0,0 +1,295 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    using static DragPointLight;
+    public class LightsPane : BasePane
+    {
+        private static readonly string[] lightTypes = { "normal", "spot", "point" };
+        private readonly LightManager lightManager;
+        private readonly Dictionary<LightProp, Slider> lightSlider;
+        private readonly Dropdown lightDropdown;
+        private readonly Button addLightButton;
+        private readonly Button deleteLightButton;
+        private readonly Button clearLightsButton;
+        private readonly Button resetPropsButton;
+        private readonly Button resetPositionButton;
+        private readonly SelectionGrid lightTypeGrid;
+        private readonly Toggle colorToggle;
+        private readonly Toggle disableToggle;
+        private MPSLightType currentLightType;
+        private string lightHeader;
+        private string resetLabel;
+
+        private static readonly Dictionary<LightProp, SliderProp> lightSliderProp;
+        private static readonly string[,] sliderNames = {
+            { "lights", "x" }, { "lights", "y" }, { "lights", "intensity" }, { "lights", "shadow" },
+            { "lights", "spot" }, { "lights", "range" }, { "backgroundWindow", "red" }, { "backgroundWindow", "green" },
+            { "backgroundWindow", "blue" }
+        };
+
+        static LightsPane()
+        {
+            Vector3 rotation = LightProperty.DefaultRotation.eulerAngles;
+            var range = GameMain.Instance.MainLight.GetComponent<Light>().range;
+            lightSliderProp = new Dictionary<LightProp, SliderProp>
+            {
+                [LightProp.LightRotX] = new SliderProp(0f, 360f, rotation.x, rotation.x),
+                [LightProp.LightRotY] = new SliderProp(0f, 360f, rotation.y, rotation.y),
+                [LightProp.Intensity] = new SliderProp(0f, 2f, 0.95f, 0.95f),
+                [LightProp.ShadowStrength] = new SliderProp(0f, 1f, 0.098f, 0.098f),
+                [LightProp.Range] = new SliderProp(0f, 150f, range, range),
+                [LightProp.SpotAngle] = new SliderProp(0f, 150f, 50f, 50f),
+                [LightProp.Red] = new SliderProp(0f, 1f, 1f, 1f),
+                [LightProp.Green] = new SliderProp(0f, 1f, 1f, 1f),
+                [LightProp.Blue] = new SliderProp(0f, 1f, 1f, 1f),
+            };
+        }
+
+        public LightsPane(LightManager lightManager)
+        {
+            this.lightManager = lightManager;
+            this.lightManager.Rotate += (s, a) => UpdateRotation();
+            this.lightManager.Scale += (s, a) => UpdateScale();
+            this.lightManager.Select += (s, a) => UpdateCurrentLight();
+            this.lightManager.ListModified += (s, a) => UpdateList();
+
+            lightTypeGrid = new SelectionGrid(Translation.GetArray("lightType", lightTypes));
+            lightTypeGrid.ControlEvent += (s, a) => SetCurrentLightType();
+
+            lightDropdown = new Dropdown(new[] { "Main" });
+            lightDropdown.SelectionChange += (s, a) => SetCurrentLight();
+
+            addLightButton = new Button("+");
+            addLightButton.ControlEvent += (s, a) => lightManager.AddLight();
+
+            deleteLightButton = new Button(Translation.Get("lightsPane", "delete"));
+            deleteLightButton.ControlEvent += (s, a) => lightManager.DeleteActiveLight();
+
+            disableToggle = new Toggle(Translation.Get("lightsPane", "disable"));
+            disableToggle.ControlEvent += (s, a) => lightManager.CurrentLight.IsDisabled = disableToggle.Value;
+
+            clearLightsButton = new Button(Translation.Get("lightsPane", "clear"));
+            clearLightsButton.ControlEvent += (s, a) => ClearLights();
+
+            var numberOfLightProps = Enum.GetNames(typeof(LightProp)).Length;
+            lightSlider = new Dictionary<LightProp, Slider>(numberOfLightProps);
+
+            for (var i = 0; i < numberOfLightProps; i++)
+            {
+                var lightProp = (LightProp)i;
+                SliderProp sliderProp = lightSliderProp[lightProp];
+                var slider = new Slider(Translation.Get(sliderNames[i, 0], sliderNames[i, 1]), sliderProp)
+                {
+                    HasTextField = true,
+                    HasReset = true
+                };
+                if (lightProp <= LightProp.LightRotY) slider.ControlEvent += (s, a) => SetLightRotation();
+                else slider.ControlEvent += (s, a) => SetLightProp(lightProp, slider.Value);
+                lightSlider[lightProp] = slider;
+            }
+
+            colorToggle = new Toggle(Translation.Get("lightsPane", "colour"));
+            colorToggle.ControlEvent += (s, a) => SetColourMode();
+
+            resetPropsButton = new Button(Translation.Get("lightsPane", "resetProperties"));
+            resetPropsButton.ControlEvent += (s, a) => ResetLightProps();
+
+            resetPositionButton = new Button(Translation.Get("lightsPane", "resetPosition"));
+            resetPositionButton.ControlEvent += (s, a) => lightManager.CurrentLight.ResetLightPosition();
+
+            lightHeader = Translation.Get("lightsPane", "header");
+            resetLabel = Translation.Get("lightsPane", "resetLabel");
+        }
+
+        protected override void ReloadTranslation()
+        {
+            updating = true;
+            lightTypeGrid.SetItems(Translation.GetArray("lightType", lightTypes));
+            lightDropdown.SetDropdownItems(lightManager.LightNameList);
+            deleteLightButton.Label = Translation.Get("lightsPane", "delete");
+            disableToggle.Label = Translation.Get("lightsPane", "disable");
+            clearLightsButton.Label = Translation.Get("lightsPane", "clear");
+            for (var lightProp = LightProp.LightRotX; lightProp <= LightProp.Blue; lightProp++)
+            {
+                lightSlider[lightProp].Label =
+                    Translation.Get(sliderNames[(int)lightProp, 0], sliderNames[(int)lightProp, 1]);
+            }
+            colorToggle.Label = Translation.Get("lightsPane", "colour");
+            resetPropsButton.Label = Translation.Get("lightsPane", "resetProperties");
+            resetPositionButton.Label = Translation.Get("lightsPane", "resetPosition");
+            lightHeader = Translation.Get("lightsPane", "header");
+            resetLabel = Translation.Get("lightsPane", "resetLabel");
+            updating = false;
+        }
+
+        private void SetColourMode()
+        {
+            lightManager.SetColourModeActive(colorToggle.Value);
+            UpdatePane();
+        }
+
+        private void ClearLights()
+        {
+            lightManager.ClearLights();
+            UpdatePane();
+        }
+
+        private void SetCurrentLight()
+        {
+            if (updating) return;
+            lightManager.SelectedLightIndex = lightDropdown.SelectedItemIndex;
+            UpdatePane();
+        }
+
+        private void ResetLightProps()
+        {
+            lightManager.CurrentLight.ResetLightProps();
+            UpdatePane();
+        }
+
+        private void SetCurrentLightType()
+        {
+            if (updating) return;
+
+            currentLightType = (MPSLightType)lightTypeGrid.SelectedItemIndex;
+
+            DragPointLight currentLight = lightManager.CurrentLight;
+
+            currentLight.SetLightType(currentLightType);
+
+            lightDropdown.SetDropdownItem(lightManager.ActiveLightName);
+            UpdatePane();
+        }
+
+        private void SetLightProp(LightProp prop, float value)
+        {
+            if (updating) return;
+            lightManager.CurrentLight.SetProp(prop, value);
+        }
+
+        private void SetLightRotation()
+        {
+            if (updating) return;
+            var lightRotX = lightSlider[LightProp.LightRotX].Value;
+            var lightRotY = lightSlider[LightProp.LightRotY].Value;
+            lightManager.CurrentLight.SetRotation(lightRotX, lightRotY);
+        }
+
+        private void UpdateList()
+        {
+            string[] newList = lightManager.LightNameList;
+            lightDropdown.SetDropdownItems(newList, lightManager.SelectedLightIndex);
+            UpdatePane();
+        }
+
+        private void UpdateRotation()
+        {
+            updating = true;
+            LightProperty prop = lightManager.CurrentLight.CurrentLightProperty;
+            lightSlider[LightProp.LightRotX].Value = prop.Rotation.eulerAngles.x;
+            lightSlider[LightProp.LightRotY].Value = prop.Rotation.eulerAngles.y;
+            updating = false;
+        }
+
+        private void UpdateScale()
+        {
+            updating = true;
+            lightSlider[LightProp.SpotAngle].Value = lightManager.CurrentLight.CurrentLightProperty.SpotAngle;
+            lightSlider[LightProp.Range].Value = lightManager.CurrentLight.CurrentLightProperty.Range;
+            updating = false;
+        }
+
+        private void UpdateCurrentLight()
+        {
+            updating = true;
+            lightDropdown.SelectedItemIndex = lightManager.SelectedLightIndex;
+            updating = false;
+            UpdatePane();
+        }
+
+        public override void UpdatePane()
+        {
+            updating = true;
+            DragPointLight currentLight = lightManager.CurrentLight;
+            currentLightType = currentLight.SelectedLightType;
+            lightTypeGrid.SelectedItemIndex = (int)currentLightType;
+            disableToggle.Value = currentLight.IsDisabled;
+            lightSlider[LightProp.LightRotX].Value = currentLight.Rotation.eulerAngles.x;
+            lightSlider[LightProp.LightRotY].Value = currentLight.Rotation.eulerAngles.y;
+            lightSlider[LightProp.Intensity].Value = currentLight.Intensity;
+            lightSlider[LightProp.ShadowStrength].Value = currentLight.ShadowStrength;
+            lightSlider[LightProp.Range].Value = currentLight.Range;
+            lightSlider[LightProp.SpotAngle].Value = currentLight.SpotAngle;
+            lightSlider[LightProp.Red].Value = currentLight.LightColour.r;
+            lightSlider[LightProp.Green].Value = currentLight.LightColour.g;
+            lightSlider[LightProp.Blue].Value = currentLight.LightColour.b;
+            updating = false;
+        }
+
+        public override void Draw()
+        {
+            var isMain = lightManager.SelectedLightIndex == 0;
+
+            GUILayoutOption noExpandWidth = GUILayout.ExpandWidth(false);
+
+            MpsGui.Header(lightHeader);
+            MpsGui.WhiteLine();
+
+            GUILayout.BeginHorizontal();
+            lightDropdown.Draw(GUILayout.Width(84));
+            addLightButton.Draw(noExpandWidth);
+
+            GUILayout.FlexibleSpace();
+            GUI.enabled = !isMain;
+            deleteLightButton.Draw(noExpandWidth);
+            GUI.enabled = true;
+            clearLightsButton.Draw(noExpandWidth);
+            GUILayout.EndHorizontal();
+
+            bool isDisabled = !isMain && lightManager.CurrentLight.IsDisabled;
+            GUILayout.BeginHorizontal();
+            GUI.enabled = !isDisabled;
+            lightTypeGrid.Draw(noExpandWidth);
+            if (!isMain)
+            {
+                GUI.enabled = true;
+                disableToggle.Draw();
+            }
+            GUILayout.EndHorizontal();
+
+            GUI.enabled = !isDisabled;
+
+            if (currentLightType != MPSLightType.Point)
+            {
+                lightSlider[LightProp.LightRotX].Draw();
+                lightSlider[LightProp.LightRotY].Draw();
+            }
+
+            lightSlider[LightProp.Intensity].Draw();
+
+            if (currentLightType == MPSLightType.Normal) lightSlider[LightProp.ShadowStrength].Draw();
+            else lightSlider[LightProp.Range].Draw();
+
+            if (currentLightType == MPSLightType.Spot) lightSlider[LightProp.SpotAngle].Draw();
+
+            MpsGui.BlackLine();
+
+            lightSlider[LightProp.Red].Draw();
+            lightSlider[LightProp.Green].Draw();
+            lightSlider[LightProp.Blue].Draw();
+
+            if (lightManager.SelectedLightIndex == 0 && currentLightType == MPSLightType.Normal) colorToggle.Draw();
+
+            GUILayout.BeginHorizontal();
+            GUILayout.Label(resetLabel, noExpandWidth);
+            resetPropsButton.Draw(noExpandWidth);
+            resetPositionButton.Draw(noExpandWidth);
+            GUILayout.EndHorizontal();
+
+            GUI.enabled = true;
+        }
+    }
+}

+ 26 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/BasePane.cs

@@ -0,0 +1,26 @@
+using System;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public abstract class BasePane
+    {
+        protected BaseWindow parent;
+        protected bool updating;
+        public virtual bool Visible { get; set; }
+        public virtual bool Enabled { get; set; }
+
+        protected BasePane() => Translation.ReloadTranslationEvent += OnReloadTranslation;
+
+        ~BasePane() => Translation.ReloadTranslationEvent -= OnReloadTranslation;
+
+        private void OnReloadTranslation(object sender, EventArgs args) => ReloadTranslation();
+
+        public virtual void SetParent(BaseWindow window) => parent = window;
+
+        protected virtual void ReloadTranslation() { }
+
+        public virtual void UpdatePane() { }
+
+        public virtual void Draw() { }
+    }
+}

+ 79 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/CallWindowPanes/MaidSelectorPane.cs

@@ -0,0 +1,79 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class MaidSelectorPane : BasePane
+    {
+        private readonly MeidoManager meidoManager;
+        private Vector2 maidListScrollPos;
+        private readonly Button clearMaidsButton;
+        private readonly Button callMaidsButton;
+        public MaidSelectorPane(MeidoManager meidoManager)
+        {
+            this.meidoManager = meidoManager;
+            clearMaidsButton = new Button(Translation.Get("maidCallWindow", "clearButton"));
+            clearMaidsButton.ControlEvent += (s, a) => this.meidoManager.ClearSelectList();
+
+            callMaidsButton = new Button(Translation.Get("maidCallWindow", "callButton"));
+            callMaidsButton.ControlEvent += (s, a) => this.meidoManager.CallMeidos();
+        }
+
+        protected override void ReloadTranslation()
+        {
+            clearMaidsButton.Label = Translation.Get("maidCallWindow", "clearButton");
+            callMaidsButton.Label = Translation.Get("maidCallWindow", "callButton");
+        }
+
+        public override void Draw()
+        {
+            GUILayout.BeginHorizontal();
+            clearMaidsButton.Draw(GUILayout.ExpandWidth(false));
+            callMaidsButton.Draw();
+            GUILayout.EndHorizontal();
+
+            GUIStyle labelStyle = new GUIStyle(GUI.skin.label) { fontSize = 14 };
+            GUIStyle selectLabelStyle = new GUIStyle(labelStyle);
+            selectLabelStyle.normal.textColor = Color.black;
+            selectLabelStyle.alignment = TextAnchor.UpperRight;
+            GUIStyle labelSelectedStyle = new GUIStyle(labelStyle);
+            labelSelectedStyle.normal.textColor = Color.black;
+
+            Rect windowRect = parent.WindowRect;
+            float windowHeight = windowRect.height;
+            float buttonWidth = windowRect.width - 30f;
+            const float buttonHeight = 85f;
+
+            Rect positionRect = new Rect(5f, 90f, windowRect.width - 10f, windowHeight - 125f);
+            Rect viewRect = new Rect(0f, 0f, buttonWidth, (buttonHeight * meidoManager.Meidos.Length) + 5f);
+            maidListScrollPos = GUI.BeginScrollView(positionRect, maidListScrollPos, viewRect);
+
+            for (int i = 0; i < meidoManager.Meidos.Length; i++)
+            {
+                Meido meido = meidoManager.Meidos[i];
+                float y = i * buttonHeight;
+                bool selectedMaid = meidoManager.SelectedMeidoSet.Contains(i);
+
+                if (GUI.Button(new Rect(0f, y, buttonWidth, buttonHeight), string.Empty)) meidoManager.SelectMeido(i);
+
+                if (selectedMaid)
+                {
+                    int selectedIndex = meidoManager.SelectMeidoList.IndexOf(i) + 1;
+                    GUI.DrawTexture(
+                        new Rect(5f, y + 5f, buttonWidth - 10f, buttonHeight - 10f), Texture2D.whiteTexture
+                    );
+                    GUI.Label(
+                        new Rect(0f, y + 5f, buttonWidth - 10f, buttonHeight),
+                        selectedIndex.ToString(), selectLabelStyle
+                    );
+                }
+
+                if (meido.Portrait) GUI.DrawTexture(new Rect(5f, y, buttonHeight, buttonHeight), meido.Portrait);
+                GUI.Label(
+                    new Rect(95f, y + 30f, buttonWidth - 80f, buttonHeight),
+                    $"{meido.LastName}\n{meido.FirstName}", selectedMaid ? labelSelectedStyle : labelStyle
+                );
+            }
+            GUI.EndScrollView();
+        }
+    }
+}

+ 159 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/FaceWindowPanes/MaidFaceBlendPane.cs

@@ -0,0 +1,159 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class MaidFaceBlendPane : BasePane
+    {
+        private readonly MeidoManager meidoManager;
+        private readonly SelectionGrid faceBlendSourceGrid;
+        private readonly Dropdown faceBlendCategoryDropdown;
+        private readonly Button prevCategoryButton;
+        private readonly Button nextCategoryButton;
+        private readonly Dropdown faceBlendDropdown;
+        private readonly Button facePrevButton;
+        private readonly Button faceNextButton;
+        private static readonly string[] tabTranslations = { "baseTab", "customTab" };
+        private bool facePresetMode;
+        private bool faceListEnabled;
+        private Dictionary<string, List<string>> CurrentFaceDict => facePresetMode
+            ? Constants.CustomFaceDict : Constants.FaceDict;
+        private List<string> CurrentFaceGroupList => facePresetMode
+            ? Constants.CustomFaceGroupList : Constants.FaceGroupList;
+        private string SelectedFaceGroup => CurrentFaceGroupList[faceBlendCategoryDropdown.SelectedItemIndex];
+        private List<string> CurrentFaceList => CurrentFaceDict[SelectedFaceGroup];
+        private int SelectedFaceIndex => faceBlendDropdown.SelectedItemIndex;
+        private string SelectedFace => CurrentFaceList[SelectedFaceIndex];
+
+        public MaidFaceBlendPane(MeidoManager meidoManager)
+        {
+            Constants.CustomFaceChange += OnPresetChange;
+            this.meidoManager = meidoManager;
+
+            faceBlendSourceGrid = new SelectionGrid(Translation.GetArray("maidFaceWindow", tabTranslations));
+            faceBlendSourceGrid.ControlEvent += (s, a) =>
+            {
+                facePresetMode = faceBlendSourceGrid.SelectedItemIndex == 1;
+                if (updating) return;
+                string[] list = facePresetMode
+                    ? CurrentFaceGroupList.ToArray()
+                    : Translation.GetArray("faceBlendCategory", Constants.FaceGroupList);
+                faceBlendCategoryDropdown.SetDropdownItems(list, 0);
+            };
+
+            faceBlendCategoryDropdown = new Dropdown(
+                Translation.GetArray("faceBlendCategory", Constants.FaceGroupList)
+            );
+            faceBlendCategoryDropdown.SelectionChange += (s, a) =>
+            {
+                faceListEnabled = CurrentFaceList.Count > 0;
+                faceBlendDropdown.SetDropdownItems(UIFaceList(), 0);
+            };
+
+            prevCategoryButton = new Button("<");
+            prevCategoryButton.ControlEvent += (s, a) => faceBlendCategoryDropdown.Step(-1);
+
+            nextCategoryButton = new Button(">");
+            nextCategoryButton.ControlEvent += (s, a) => faceBlendCategoryDropdown.Step(1);
+
+            faceBlendDropdown = new Dropdown(UIFaceList());
+            faceBlendDropdown.SelectionChange += (s, a) =>
+            {
+                if (!faceListEnabled || updating) return;
+                this.meidoManager.ActiveMeido.SetFaceBlendSet(SelectedFace);
+            };
+
+            facePrevButton = new Button("<");
+            facePrevButton.ControlEvent += (s, a) => faceBlendDropdown.Step(-1);
+
+            faceNextButton = new Button(">");
+            faceNextButton.ControlEvent += (s, a) => faceBlendDropdown.Step(1);
+
+            faceListEnabled = CurrentFaceList.Count > 0;
+        }
+
+        protected override void ReloadTranslation()
+        {
+            updating = true;
+            faceBlendSourceGrid.SetItems(Translation.GetArray("maidFaceWindow", tabTranslations));
+            if (!facePresetMode)
+            {
+                faceBlendCategoryDropdown.SetDropdownItems(
+                    Translation.GetArray("faceBlendCategory", Constants.FaceGroupList)
+                );
+            }
+            updating = false;
+        }
+
+        public override void Draw()
+        {
+            const float buttonHeight = 30;
+            GUILayoutOption[] arrowLayoutOptions = {
+                GUILayout.Width(buttonHeight),
+                GUILayout.Height(buttonHeight)
+            };
+
+            const float dropdownButtonWidth = 153f;
+            GUILayoutOption[] dropdownLayoutOptions = new GUILayoutOption[] {
+                GUILayout.Height(buttonHeight),
+                GUILayout.Width(dropdownButtonWidth)
+            };
+
+            GUI.enabled = meidoManager.HasActiveMeido;
+
+            faceBlendSourceGrid.Draw();
+
+            MpsGui.WhiteLine();
+
+            GUILayout.BeginHorizontal();
+            prevCategoryButton.Draw(arrowLayoutOptions);
+            faceBlendCategoryDropdown.Draw(dropdownLayoutOptions);
+            nextCategoryButton.Draw(arrowLayoutOptions);
+            GUILayout.EndHorizontal();
+
+            GUILayout.BeginHorizontal();
+            GUI.enabled = GUI.enabled && faceListEnabled;
+            facePrevButton.Draw(arrowLayoutOptions);
+            faceBlendDropdown.Draw(dropdownLayoutOptions);
+            faceNextButton.Draw(arrowLayoutOptions);
+            GUILayout.EndHorizontal();
+            GUI.enabled = true;
+        }
+
+        private string[] UIFaceList()
+        {
+            return CurrentFaceList.Count == 0
+                ? (new[] { "No Face Presets" })
+                : CurrentFaceList.Select(face => facePresetMode
+                    ? Path.GetFileNameWithoutExtension(face)
+                    : Translation.Get("faceBlendPresetsDropdown", face)
+                ).ToArray();
+        }
+
+        private void OnPresetChange(object sender, PresetChangeEventArgs args)
+        {
+            if (args == PresetChangeEventArgs.Empty)
+            {
+                if (facePresetMode)
+                {
+                    updating = true;
+                    faceBlendCategoryDropdown.SetDropdownItems(CurrentFaceGroupList.ToArray(), 0);
+                    faceBlendDropdown.SetDropdownItems(UIFaceList(), 0);
+                    updating = false;
+                }
+            }
+            else
+            {
+                updating = true;
+                faceBlendSourceGrid.SelectedItemIndex = 1;
+                faceBlendCategoryDropdown.SetDropdownItems(
+                    CurrentFaceGroupList.ToArray(), CurrentFaceGroupList.IndexOf(args.Category)
+                );
+                updating = false;
+                faceBlendDropdown.SetDropdownItems(UIFaceList(), CurrentFaceList.IndexOf(args.Path));
+            }
+        }
+    }
+}

+ 214 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/FaceWindowPanes/MaidFaceSliderPane.cs

@@ -0,0 +1,214 @@
+using System;
+using System.IO;
+using System.Collections.Generic;
+using Newtonsoft.Json;
+using UnityEngine;
+using System.Linq;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    using static Meido;
+    public class MaidFaceSliderPane : BasePane
+    {
+        private static readonly Dictionary<string, float> SliderLimits = new Dictionary<string, float>()
+        {
+            // Eye Shut
+            ["eyeclose"] = 1f,
+            // Eye Smile
+            ["eyeclose2"] = 1f,
+            // Glare
+            ["eyeclose3"] = 1f,
+            // Wide Eyes
+            ["eyebig"] = 1f,
+            // Wink 1
+            ["eyeclose6"] = 1f,
+            // Wink 2
+            ["eyeclose5"] = 1f,
+            // Highlight
+            ["hitomih"] = 2f,
+            // Pupil Size
+            ["hitomis"] = 3f,
+            // Brow 1
+            ["mayuha"] = 1f,
+            // Brow 2
+            ["mayuw"] = 1f,
+            // Brow Up
+            ["mayuup"] = 1f,
+            // Brow Down 1
+            ["mayuv"] = 1f,
+            // Brow Down 2
+            ["mayuvhalf"] = 1f,
+            // Mouth Open 1
+            ["moutha"] = 1f,
+            // Mouth Open 2
+            ["mouths"] = 1f,
+            // Mouth Narrow
+            ["mouthc"] = 1f,
+            // Mouth Widen
+            ["mouthi"] = 1f,
+            // Smile
+            ["mouthup"] = 1.4f,
+            // Frown
+            ["mouthdw"] = 1f,
+            // Mouth Pucker
+            ["mouthhe"] = 1f,
+            // Grin
+            ["mouthuphalf"] = 2f,
+            // Tongue Out
+            ["tangout"] = 1f,
+            // Tongue Up
+            ["tangup"] = 1f,
+            // Tongue Base
+            ["tangopen"] = 1f
+        };
+        private readonly MeidoManager meidoManager;
+        private readonly Dictionary<string, BaseControl> faceControls;
+        private bool hasTangOpen;
+
+        public MaidFaceSliderPane(MeidoManager meidoManager)
+        {
+            this.meidoManager = meidoManager;
+            faceControls = new Dictionary<string, BaseControl>();
+
+            foreach (string key in faceKeys)
+            {
+                string uiName = Translation.Get("faceBlendValues", key);
+                Slider slider = new Slider(uiName, 0f, SliderLimits[key]);
+                string myKey = key;
+                slider.ControlEvent += (s, a) => SetFaceValue(myKey, slider.Value);
+                faceControls[key] = slider;
+            }
+
+            foreach (string key in faceToggleKeys)
+            {
+                string uiName = Translation.Get("faceBlendValues", key);
+                Toggle toggle = new Toggle(uiName);
+                string myKey = key;
+                toggle.ControlEvent += (s, a) => SetFaceValue(myKey, toggle.Value);
+                faceControls[key] = toggle;
+            }
+
+            InitializeSliderLimits(faceControls);
+        }
+
+        private static void InitializeSliderLimits(Dictionary<string, BaseControl> controls)
+        {
+            try
+            {
+                string sliderLimitsPath = Path.Combine(Constants.databasePath, "face_slider_limits.json");
+                string sliderLimitsJson = File.ReadAllText(sliderLimitsPath);
+
+                foreach (var kvp in JsonConvert.DeserializeObject<Dictionary<string, float>>(sliderLimitsJson))
+                {
+                    string key = kvp.Key;
+                    if (faceKeys.Contains(key) && controls.ContainsKey(key))
+                    {
+                        float limit = kvp.Value;
+                        limit = kvp.Value >= 1f ? limit : SliderLimits[key];
+                        Slider slider = (Slider)controls[kvp.Key];
+                        slider.SetBounds(slider.Left, limit);
+                    }
+                    else Utility.LogWarning($"'{key}' is not a valid face key");
+                }
+            }
+            catch (IOException e)
+            {
+                Utility.LogWarning($"Could not open face slider limit database because {e.Message}");
+            }
+            catch (Exception e)
+            {
+                Utility.LogError($"Could not apply face slider limit database because {e.Message}");
+            }
+        }
+
+        protected override void ReloadTranslation()
+        {
+            for (int i = 0; i < faceKeys.Length; i++)
+            {
+                Slider slider = (Slider)faceControls[faceKeys[i]];
+                slider.Label = Translation.Get("faceBlendValues", faceKeys[i]);
+            }
+
+            for (int i = 0; i < faceToggleKeys.Length; i++)
+            {
+                Toggle toggle = (Toggle)faceControls[faceToggleKeys[i]];
+                toggle.Label = Translation.Get("faceBlendValues", faceToggleKeys[i]);
+            }
+        }
+
+        public override void UpdatePane()
+        {
+            updating = true;
+            Meido meido = meidoManager.ActiveMeido;
+            for (int i = 0; i < faceKeys.Length; i++)
+            {
+                Slider slider = (Slider)faceControls[faceKeys[i]];
+                try
+                {
+                    slider.Value = meido.GetFaceBlendValue(faceKeys[i]);
+                }
+                catch { }
+            }
+
+            for (int i = 0; i < faceToggleKeys.Length; i++)
+            {
+                string hash = faceToggleKeys[i];
+                Toggle toggle = (Toggle)faceControls[hash];
+                toggle.Value = meido.GetFaceBlendValue(hash) > 0f;
+                if (hash == "toothoff") toggle.Value = !toggle.Value;
+            }
+            hasTangOpen = meido.Body.Face.morph.Contains("tangopen");
+            updating = false;
+        }
+
+        public override void Draw()
+        {
+            GUI.enabled = meidoManager.HasActiveMeido;
+            DrawSliders("eyeclose", "eyeclose2");
+            DrawSliders("eyeclose3", "eyebig");
+            DrawSliders("eyeclose6", "eyeclose5");
+            DrawSliders("hitomih", "hitomis");
+            DrawSliders("mayuha", "mayuw");
+            DrawSliders("mayuup", "mayuv");
+            DrawSliders("mayuvhalf");
+            DrawSliders("moutha", "mouths");
+            DrawSliders("mouthc", "mouthi");
+            DrawSliders("mouthup", "mouthdw");
+            DrawSliders("mouthhe", "mouthuphalf");
+            DrawSliders("tangout", "tangup");
+            if (hasTangOpen) DrawSliders("tangopen");
+            MpsGui.WhiteLine();
+            DrawToggles("hoho2", "shock", "nosefook");
+            DrawToggles("namida", "yodare", "toothoff");
+            DrawToggles("tear1", "tear2", "tear3");
+            DrawToggles("hohos", "hoho", "hohol");
+            GUI.enabled = true;
+        }
+
+        private void DrawSliders(params string[] keys)
+        {
+            GUILayout.BeginHorizontal();
+            foreach (string key in keys) faceControls[key].Draw(MpsGui.HalfSlider);
+            GUILayout.EndHorizontal();
+        }
+
+        private void DrawToggles(params string[] keys)
+        {
+            GUILayout.BeginHorizontal();
+            foreach (string key in keys) faceControls[key].Draw();
+            GUILayout.EndHorizontal();
+        }
+
+        private void SetFaceValue(string key, float value)
+        {
+            if (updating) return;
+            meidoManager.ActiveMeido.SetFaceBlendValue(key, value);
+        }
+
+        private void SetFaceValue(string key, bool value)
+        {
+            if (key == "toothoff") value = !value;
+            SetFaceValue(key, value ? 1f : 0f);
+        }
+    }
+}

+ 63 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/FaceWindowPanes/SaveFacePane.cs

@@ -0,0 +1,63 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class SaveFacePane : BasePane
+    {
+        private readonly MeidoManager meidoManager;
+        private readonly ComboBox categoryComboBox;
+        private readonly TextField faceNameTextField;
+        private readonly Button saveFaceButton;
+        private string categoryHeader;
+        private string nameHeader;
+
+        public SaveFacePane(MeidoManager meidoManager)
+        {
+            Constants.CustomFaceChange += (s, a)
+                => categoryComboBox.SetDropdownItems(Constants.CustomFaceGroupList.ToArray());
+
+            this.meidoManager = meidoManager;
+
+            categoryHeader = Translation.Get("faceSave", "categoryHeader");
+            nameHeader = Translation.Get("faceSave", "nameHeader");
+
+            saveFaceButton = new Button(Translation.Get("faceSave", "saveButton"));
+            saveFaceButton.ControlEvent += (s, a) => SaveFace();
+
+            categoryComboBox = new ComboBox(Constants.CustomFaceGroupList.ToArray());
+            faceNameTextField = new TextField();
+        }
+
+        protected override void ReloadTranslation()
+        {
+            categoryHeader = Translation.Get("faceSave", "categoryHeader");
+            nameHeader = Translation.Get("faceSave", "nameHeader");
+            saveFaceButton.Label = Translation.Get("faceSave", "saveButton");
+        }
+
+        public override void Draw()
+        {
+            GUI.enabled = meidoManager.HasActiveMeido;
+
+            MpsGui.Header(categoryHeader);
+            categoryComboBox.Draw(GUILayout.Width(165f));
+
+            MpsGui.Header(nameHeader);
+            GUILayout.BeginHorizontal();
+            faceNameTextField.Draw(GUILayout.Width(160f));
+            saveFaceButton.Draw(GUILayout.ExpandWidth(false));
+            GUILayout.EndHorizontal();
+
+            GUI.enabled = true;
+        }
+
+        private void SaveFace()
+        {
+            if (!meidoManager.HasActiveMeido) return;
+
+            Meido meido = meidoManager.ActiveMeido;
+            Constants.AddFacePreset(meido.SerializeFace(), faceNameTextField.Value, categoryComboBox.Value);
+            faceNameTextField.Value = string.Empty;
+        }
+    }
+}

+ 59 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/MainWindowPanes/BG2WindowPane.cs

@@ -0,0 +1,59 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class BG2WindowPane : BaseMainWindowPane
+    {
+        private static readonly string[] tabNames = { "props", "myRoom", "mod" };
+        private readonly MeidoManager meidoManager;
+        private readonly PropManager propManager;
+        private readonly AttachPropPane attachPropPane;
+        private readonly PropManagerPane propManagerPane;
+        private readonly SelectionGrid propTabs;
+        private BasePane currentPropsPane;
+
+        public BG2WindowPane(MeidoManager meidoManager, PropManager propManager)
+        {
+            this.meidoManager = meidoManager;
+            this.propManager = propManager;
+            this.propManager.FromPropSelect += (s, a) => propTabs.SelectedItemIndex = 0;
+
+            // should be added in this order
+            AddPane(new PropsPane(propManager));
+            AddPane(new MyRoomPropsPane(propManager));
+            AddPane(new ModPropsPane(propManager));
+
+            attachPropPane = AddPane(new AttachPropPane(this.meidoManager, propManager));
+            propManagerPane = AddPane(new PropManagerPane(propManager));
+
+            propTabs = new SelectionGrid(Translation.GetArray("propsPaneTabs", tabNames));
+            propTabs.ControlEvent += (s, a) => currentPropsPane = Panes[propTabs.SelectedItemIndex];
+            currentPropsPane = Panes[0];
+        }
+
+        protected override void ReloadTranslation()
+        {
+            propTabs.SetItems(Translation.GetArray("propsPaneTabs", tabNames));
+        }
+
+        public override void Draw()
+        {
+            tabsPane.Draw();
+            propTabs.Draw();
+            MpsGui.WhiteLine();
+            currentPropsPane.Draw();
+
+            if (propTabs.SelectedItemIndex != 0) return;
+
+            propManagerPane.Draw();
+            scrollPos = GUILayout.BeginScrollView(scrollPos);
+            attachPropPane.Draw();
+            GUILayout.EndScrollView();
+        }
+
+        public override void UpdatePanes()
+        {
+            if (ActiveWindow) base.UpdatePanes();
+        }
+    }
+}

+ 66 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/MainWindowPanes/BGWindowPane.cs

@@ -0,0 +1,66 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class BGWindowPane : BaseMainWindowPane
+    {
+        private readonly BackgroundSelectorPane backgroundSelectorPane;
+        private readonly CameraPane cameraPane;
+        private readonly LightsPane lightsPane;
+        private readonly EffectsPane effectsPane;
+        private readonly DragPointPane dragPointPane;
+        private readonly OtherEffectsPane otherEffectsPane;
+        private readonly Button sceneManagerButton;
+
+        public BGWindowPane(
+            EnvironmentManager environmentManager, LightManager lightManager, EffectManager effectManager,
+            SceneWindow sceneWindow, CameraManager cameraManager
+        )
+        {
+            sceneManagerButton = new Button(Translation.Get("backgroundWindow", "manageScenesButton"));
+            sceneManagerButton.ControlEvent += (s, a) => sceneWindow.Visible = !sceneWindow.Visible;
+
+            backgroundSelectorPane = AddPane(new BackgroundSelectorPane(environmentManager));
+            cameraPane = AddPane(new CameraPane(cameraManager));
+            dragPointPane = AddPane(new DragPointPane());
+            lightsPane = AddPane(new LightsPane(lightManager));
+
+            effectsPane = AddPane(new EffectsPane()
+            {
+                ["bloom"] = new BloomPane(effectManager),
+                ["dof"] = new DepthOfFieldPane(effectManager),
+                ["vignette"] = new VignettePane(effectManager),
+                ["fog"] = new FogPane(effectManager)
+            });
+
+            otherEffectsPane = AddPane(new OtherEffectsPane(effectManager));
+        }
+
+        protected override void ReloadTranslation()
+        {
+            sceneManagerButton.Label = Translation.Get("backgroundWindow", "manageScenesButton");
+        }
+
+        public override void Draw()
+        {
+            tabsPane.Draw();
+            sceneManagerButton.Draw();
+            backgroundSelectorPane.Draw();
+            dragPointPane.Draw();
+
+            scrollPos = GUILayout.BeginScrollView(scrollPos);
+
+            cameraPane.Draw();
+            lightsPane.Draw();
+            effectsPane.Draw();
+            otherEffectsPane.Draw();
+
+            GUILayout.EndScrollView();
+        }
+
+        public override void UpdatePanes()
+        {
+            if (ActiveWindow) base.UpdatePanes();
+        }
+    }
+}

+ 13 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/MainWindowPanes/BaseMainWindowPane.cs

@@ -0,0 +1,13 @@
+namespace MeidoPhotoStudio.Plugin
+{
+    public abstract class BaseMainWindowPane : BaseWindowPane
+    {
+        protected TabsPane tabsPane;
+        public void SetTabsPane(TabsPane tabsPane) => this.tabsPane = tabsPane;
+        /* Main window panes have panes within them while being a pane itself of the main window */
+        public override void SetParent(BaseWindow window)
+        {
+            foreach (BasePane pane in Panes) pane.SetParent(window);
+        }
+    }
+}

+ 47 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/MainWindowPanes/CallWindowPane.cs

@@ -0,0 +1,47 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class CallWindowPane : BaseMainWindowPane
+    {
+        private readonly MeidoManager meidoManager;
+        private readonly MaidSelectorPane maidSelectorPane;
+        private readonly Dropdown placementDropdown;
+        private readonly Button placementOKButton;
+
+        public CallWindowPane(MeidoManager meidoManager)
+        {
+            this.meidoManager = meidoManager;
+            placementDropdown = new Dropdown(
+                Translation.GetArray("placementDropdown", MaidPlacementUtility.placementTypes)
+            );
+
+            placementOKButton = new Button(Translation.Get("maidCallWindow", "okButton"));
+            placementOKButton.ControlEvent += (o, a) => this.meidoManager.PlaceMeidos(
+                MaidPlacementUtility.placementTypes[placementDropdown.SelectedItemIndex]
+            );
+
+            maidSelectorPane = AddPane(new MaidSelectorPane(this.meidoManager));
+        }
+
+        protected override void ReloadTranslation()
+        {
+            placementDropdown.SetDropdownItems(
+                Translation.GetArray("placementDropdown", MaidPlacementUtility.placementTypes)
+            );
+            placementOKButton.Label = Translation.Get("maidCallWindow", "okButton");
+        }
+
+        public override void Draw()
+        {
+            tabsPane.Draw();
+
+            GUILayout.BeginHorizontal();
+            placementDropdown.Draw(GUILayout.Width(150));
+            placementOKButton.Draw();
+            GUILayout.EndHorizontal();
+
+            maidSelectorPane.Draw();
+        }
+    }
+}

+ 64 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/MainWindowPanes/FaceWindowPane.cs

@@ -0,0 +1,64 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class FaceWindowPane : BaseMainWindowPane
+    {
+        private readonly MeidoManager meidoManager;
+        private readonly MaidFaceSliderPane maidFaceSliderPane;
+        private readonly MaidFaceBlendPane maidFaceBlendPane;
+        private readonly MaidSwitcherPane maidSwitcherPane;
+        private readonly SaveFacePane saveFacePane;
+        private readonly Toggle saveFaceToggle;
+        private bool saveFaceMode;
+
+        public FaceWindowPane(MeidoManager meidoManager, MaidSwitcherPane maidSwitcherPane)
+        {
+            this.meidoManager = meidoManager;
+
+            this.maidSwitcherPane = maidSwitcherPane;
+
+            maidFaceSliderPane = AddPane(new MaidFaceSliderPane(this.meidoManager));
+            maidFaceBlendPane = AddPane(new MaidFaceBlendPane(this.meidoManager));
+            saveFacePane = AddPane(new SaveFacePane(this.meidoManager));
+
+            saveFaceToggle = new Toggle(Translation.Get("maidFaceWindow", "savePaneToggle"));
+            saveFaceToggle.ControlEvent += (s, a) => saveFaceMode = !saveFaceMode;
+        }
+
+        protected override void ReloadTranslation()
+        {
+            saveFaceToggle.Label = Translation.Get("maidFaceWindow", "savePaneToggle");
+        }
+
+        public override void Draw()
+        {
+            tabsPane.Draw();
+            maidSwitcherPane.Draw();
+
+            maidFaceBlendPane.Draw();
+
+            scrollPos = GUILayout.BeginScrollView(scrollPos);
+
+            maidFaceSliderPane.Draw();
+
+            GUI.enabled = meidoManager.HasActiveMeido;
+            saveFaceToggle.Draw();
+            GUI.enabled = true;
+
+            if (saveFaceMode) saveFacePane.Draw();
+
+            GUILayout.EndScrollView();
+        }
+
+        public override void UpdatePanes()
+        {
+            if (!meidoManager.HasActiveMeido) return;
+            if (ActiveWindow)
+            {
+                meidoManager.ActiveMeido.StopBlink();
+                base.UpdatePanes();
+            }
+        }
+    }
+}

+ 147 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/MainWindowPanes/PoseWindowPane.cs

@@ -0,0 +1,147 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class PoseWindowPane : BaseMainWindowPane
+    {
+        private readonly MeidoManager meidoManager;
+        private readonly MaidPoseSelectorPane maidPosePane;
+        private readonly SavePosePane savePosePane;
+        private readonly MaidSwitcherPane maidSwitcherPane;
+        private readonly MaidFaceLookPane maidFaceLookPane;
+        private readonly MpnAttachPropPane mpnAttachPropPane;
+        private readonly MaidDressingPane maidDressingPane;
+        private readonly GravityControlPane gravityControlPane;
+        private readonly CopyPosePane copyPosePane;
+        private readonly HandPresetPane handPresetPane;
+        private readonly SaveHandPane saveHandPane;
+        private readonly MaidIKPane maidIKPane;
+        private readonly Toggle freeLookToggle;
+        private readonly Toggle savePoseToggle;
+        private readonly Toggle saveHandToggle;
+        private readonly Button flipButton;
+        private bool savePoseMode;
+        private bool saveHandMode;
+        private string handPresetHeader;
+        private string flipIKHeader;
+
+        public PoseWindowPane(MeidoManager meidoManager, MaidSwitcherPane maidSwitcherPane)
+        {
+            this.meidoManager = meidoManager;
+            this.maidSwitcherPane = maidSwitcherPane;
+
+            maidPosePane = AddPane(new MaidPoseSelectorPane(meidoManager));
+            savePosePane = AddPane(new SavePosePane(meidoManager));
+
+            maidFaceLookPane = AddPane(new MaidFaceLookPane(meidoManager));
+            maidFaceLookPane.Enabled = false;
+
+            freeLookToggle = new Toggle(Translation.Get("freeLookPane", "freeLookToggle"), false);
+            freeLookToggle.ControlEvent += (s, a) => SetMaidFreeLook();
+
+            savePoseToggle = new Toggle(Translation.Get("posePane", "saveToggle"));
+            savePoseToggle.ControlEvent += (s, a) => savePoseMode = !savePoseMode;
+
+            mpnAttachPropPane = new MpnAttachPropPane(this.meidoManager);
+
+            maidDressingPane = AddPane(new MaidDressingPane(this.meidoManager));
+
+            maidIKPane = AddPane(new MaidIKPane(this.meidoManager));
+
+            gravityControlPane = AddPane(new GravityControlPane(this.meidoManager));
+
+            copyPosePane = AddPane(new CopyPosePane(this.meidoManager));
+
+            saveHandToggle = new Toggle(Translation.Get("handPane", "saveToggle"));
+            saveHandToggle.ControlEvent += (s, a) => saveHandMode = !saveHandMode;
+
+            handPresetPane = AddPane(new HandPresetPane(meidoManager));
+            saveHandPane = AddPane(new SaveHandPane(meidoManager));
+
+            flipButton = new Button(Translation.Get("flipIK", "flipButton"));
+            flipButton.ControlEvent += (s, a) => this.meidoManager.ActiveMeido.IKManager.Flip();
+
+            handPresetHeader = Translation.Get("handPane", "header");
+            flipIKHeader = Translation.Get("flipIK", "header");
+        }
+
+        protected override void ReloadTranslation()
+        {
+            freeLookToggle.Label = Translation.Get("freeLookPane", "freeLookToggle");
+            savePoseToggle.Label = Translation.Get("posePane", "saveToggle");
+            saveHandToggle.Label = Translation.Get("handPane", "saveToggle");
+            flipButton.Label = Translation.Get("flipIK", "flipButton");
+            handPresetHeader = Translation.Get("handPane", "header");
+            flipIKHeader = Translation.Get("flipIK", "header");
+        }
+
+        public override void Draw()
+        {
+            tabsPane.Draw();
+
+            maidSwitcherPane.Draw();
+            maidPosePane.Draw();
+
+            maidIKPane.Draw();
+
+            MpsGui.WhiteLine();
+
+            scrollPos = GUILayout.BeginScrollView(scrollPos);
+
+            GUI.enabled = meidoManager.HasActiveMeido;
+            GUILayout.BeginHorizontal();
+            freeLookToggle.Draw();
+            savePoseToggle.Draw();
+            GUILayout.EndHorizontal();
+            GUI.enabled = true;
+
+            if (savePoseMode) savePosePane.Draw();
+            else maidFaceLookPane.Draw();
+
+            mpnAttachPropPane.Draw();
+
+            maidDressingPane.Draw();
+
+            GUI.enabled = meidoManager.HasActiveMeido;
+            MpsGui.Header(handPresetHeader);
+            MpsGui.WhiteLine();
+            saveHandToggle.Draw();
+            GUI.enabled = true;
+
+            if (saveHandMode) saveHandPane.Draw();
+            else handPresetPane.Draw();
+
+            gravityControlPane.Draw();
+
+            copyPosePane.Draw();
+
+            GUILayout.BeginHorizontal();
+            GUI.enabled = meidoManager.HasActiveMeido;
+            GUILayout.Label(flipIKHeader, GUILayout.ExpandWidth(false));
+            flipButton.Draw(GUILayout.ExpandWidth(false));
+            GUI.enabled = true;
+            GUILayout.EndHorizontal();
+
+            GUILayout.EndScrollView();
+        }
+
+        private void SetMaidFreeLook()
+        {
+            if (updating) return;
+            meidoManager.ActiveMeido.FreeLook = freeLookToggle.Value;
+        }
+
+        public override void UpdatePanes()
+        {
+            if (meidoManager.ActiveMeido == null) return;
+
+            if (ActiveWindow)
+            {
+                updating = true;
+                freeLookToggle.Value = meidoManager.ActiveMeido?.FreeLook ?? false;
+                updating = false;
+                base.UpdatePanes();
+            }
+        }
+    }
+}

+ 150 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/MainWindowPanes/SettingsWindowPane.cs

@@ -0,0 +1,150 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class SettingsWindowPane : BaseMainWindowPane
+    {
+        private static readonly string[] headerTranslationKeys = {
+            "controls", "controlsGeneral", "controlsMaids", "controlsCamera", "controlsDragPoint", "controlsScene"
+        };
+        private static readonly Dictionary<string, string> headers = new Dictionary<string, string>();
+        private static readonly string[] actionTranslationKeys;
+        private static readonly string[] actionLabels;
+        private readonly Button reloadTranslationButton;
+        private readonly Button reloadAllPresetsButton;
+        private readonly KeyRebindButton[] rebindButtons;
+
+        static SettingsWindowPane()
+        {
+            actionTranslationKeys = Enum.GetNames(typeof(MpsKey))
+                .Select(action => char.ToLowerInvariant(action[0]) + action.Substring(1))
+                .ToArray();
+            actionLabels = new string[actionTranslationKeys.Length];
+        }
+
+        public SettingsWindowPane()
+        {
+            rebindButtons = new KeyRebindButton[actionTranslationKeys.Length];
+
+            for (int i = 0; i < rebindButtons.Length; i++)
+            {
+                MpsKey action = (MpsKey)i;
+                KeyRebindButton button = new KeyRebindButton(KeyCode.None);
+                button.ControlEvent += (s, a) => InputManager.Rebind(action, button.KeyCode);
+                rebindButtons[i] = button;
+
+                actionLabels[i] = Translation.Get("controls", actionTranslationKeys[i]);
+            }
+
+            for (int i = 0; i < headerTranslationKeys.Length; i++)
+            {
+                headers[headerTranslationKeys[i]] = Translation.Get("settingsHeaders", headerTranslationKeys[i]);
+            }
+
+            reloadTranslationButton = new Button(Translation.Get("settingsLabels", "reloadTranslation"));
+            reloadTranslationButton.ControlEvent += (s, a) => Translation.ReinitializeTranslation();
+
+            reloadAllPresetsButton = new Button(Translation.Get("settingsLabels", "reloadAllPresets"));
+            reloadAllPresetsButton.ControlEvent += (s, a) =>
+            {
+                Constants.InitializeCustomFaceBlends();
+                Constants.InitializeHandPresets();
+                Constants.InitializeCustomPoses();
+            };
+        }
+
+        protected override void ReloadTranslation()
+        {
+            for (int i = 0; i < rebindButtons.Length; i++)
+            {
+                actionLabels[i] = Translation.Get("controls", actionTranslationKeys[i]);
+            }
+
+            for (int i = 0; i < headerTranslationKeys.Length; i++)
+            {
+                headers[headerTranslationKeys[i]] = Translation.Get("settingsHeaders", headerTranslationKeys[i]);
+            }
+
+            reloadTranslationButton.Label = Translation.Get("settingsLabels", "reloadTranslation");
+            reloadAllPresetsButton.Label = Translation.Get("settingsLabels", "reloadAllPresets");
+        }
+
+        public override void Draw()
+        {
+            scrollPos = GUILayout.BeginScrollView(scrollPos);
+
+            MpsGui.Header(headers["controls"]);
+            MpsGui.WhiteLine();
+
+            MpsGui.Header(headers["controlsGeneral"]);
+            MpsGui.WhiteLine();
+            for (MpsKey key = MpsKey.Activate; key <= MpsKey.ToggleMessage; key++)
+            {
+                DrawSetting(key);
+            }
+
+            MpsGui.Header(headers["controlsMaids"]);
+            MpsGui.WhiteLine();
+            DrawSetting(MpsKey.MeidoUndressing);
+
+            MpsGui.Header(headers["controlsCamera"]);
+            MpsGui.WhiteLine();
+            for (MpsKey key = MpsKey.CameraLayer; key <= MpsKey.CameraLoad; key++)
+            {
+                DrawSetting(key);
+            }
+
+            MpsGui.Header(headers["controlsDragPoint"]);
+            MpsGui.WhiteLine();
+            for (MpsKey key = MpsKey.DragSelect; key <= MpsKey.DragFinger; key++)
+            {
+                DrawSetting(key);
+            }
+
+            MpsGui.Header(headers["controlsScene"]);
+            MpsGui.WhiteLine();
+            for (MpsKey key = MpsKey.SaveScene; key <= MpsKey.OpenSceneManager; key++)
+            {
+                DrawSetting(key);
+            }
+
+            GUI.enabled = !InputManager.Listening;
+
+            // Translation settings
+            MpsGui.WhiteLine();
+            reloadTranslationButton.Draw();
+
+            reloadAllPresetsButton.Draw();
+
+            GUILayout.EndScrollView();
+
+            GUI.enabled = true;
+        }
+
+        private void DrawSetting(MpsKey key)
+        {
+            int keyIndex = (int)key;
+            GUILayout.BeginHorizontal();
+            GUILayout.Label(actionLabels[keyIndex]);
+            GUILayout.FlexibleSpace();
+            rebindButtons[keyIndex].Draw(GUILayout.Width(90f));
+            if (GUILayout.Button("×", GUILayout.ExpandWidth(false)))
+            {
+                rebindButtons[keyIndex].KeyCode = KeyCode.None;
+                InputManager.Rebind(key, KeyCode.None);
+            }
+            GUILayout.EndHorizontal();
+        }
+
+        public override void UpdatePanes()
+        {
+            for (int i = 0; i < rebindButtons.Length; i++)
+            {
+                rebindButtons[i].KeyCode = InputManager.GetActionKey((MpsKey)i);
+            }
+        }
+    }
+}

+ 119 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/OtherPanes/MaidSwitcherPane.cs

@@ -0,0 +1,119 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class MaidSwitcherPane : BasePane
+    {
+        private readonly MeidoManager meidoManager;
+        private readonly Button previousButton;
+        private readonly Button nextButton;
+        private readonly Toggle editToggle;
+
+        public MaidSwitcherPane(MeidoManager meidoManager)
+        {
+            this.meidoManager = meidoManager;
+            this.meidoManager.UpdateMeido += (s, a) => UpdatePane();
+
+            previousButton = new Button("<");
+            previousButton.ControlEvent += (s, a) => ChangeMaid(-1);
+
+            nextButton = new Button(">");
+            nextButton.ControlEvent += (s, a) => ChangeMaid(1);
+
+            editToggle = new Toggle("Edit", true);
+            editToggle.ControlEvent += (s, a) => SetEditMaid();
+        }
+
+        public override void Draw()
+        {
+            const float boxSize = 70;
+            const int margin = (int)(boxSize / 2.8f);
+
+            GUIStyle buttonStyle = new GUIStyle(GUI.skin.button);
+            buttonStyle.margin.top = margin;
+
+            GUIStyle labelStyle = new GUIStyle(GUI.skin.label);
+            labelStyle.margin.top = margin;
+
+            GUIStyle boxStyle = new GUIStyle(GUI.skin.box) { margin = new RectOffset(0, 0, 0, 0) };
+            GUIStyle horizontalStyle = new GUIStyle { padding = new RectOffset(4, 4, 0, 0) };
+
+            GUILayoutOption[] buttonOptions = new[] { GUILayout.ExpandHeight(true), GUILayout.ExpandWidth(false) };
+            GUILayoutOption[] boxLayoutOptions = new[] { GUILayout.Height(boxSize), GUILayout.Width(boxSize) };
+
+            GUI.enabled = meidoManager.HasActiveMeido;
+            Meido meido = meidoManager.ActiveMeido;
+
+            GUILayout.BeginHorizontal(horizontalStyle, GUILayout.Height(boxSize));
+
+            previousButton.Draw(buttonStyle, buttonOptions);
+
+            GUILayout.Space(20);
+
+            if (meidoManager.HasActiveMeido && meido.Portrait) MpsGui.DrawTexture(meido.Portrait, boxLayoutOptions);
+            else GUILayout.Box(GUIContent.none, boxStyle, boxLayoutOptions);
+
+            string label = meidoManager.HasActiveMeido ? $"{meido.LastName}\n{meido.FirstName}" : string.Empty;
+
+            GUILayout.Label(label, labelStyle, GUILayout.ExpandWidth(false));
+
+            GUILayout.FlexibleSpace();
+
+            nextButton.Draw(buttonStyle, buttonOptions);
+
+            GUILayout.EndHorizontal();
+
+            Rect previousRect = GUILayoutUtility.GetLastRect();
+
+            if (MeidoPhotoStudio.EditMode) editToggle.Draw(new Rect(previousRect.x + 4f, previousRect.y, 40f, 20f));
+
+            Rect labelRect = new Rect(previousRect.width - 45f, previousRect.y, 40f, 20f);
+            GUIStyle slotStyle = new GUIStyle()
+            {
+                alignment = TextAnchor.UpperRight,
+                fontSize = 13
+            };
+            slotStyle.padding.right = 5;
+            slotStyle.normal.textColor = Color.white;
+
+            if (meidoManager.HasActiveMeido) GUI.Label(labelRect, $"{meidoManager.ActiveMeido.Slot + 1}", slotStyle);
+        }
+
+        public override void UpdatePane()
+        {
+            if (meidoManager.HasActiveMeido)
+            {
+                this.updating = true;
+                editToggle.Value = meidoManager.ActiveMeido.IsEditMaid;
+                this.updating = false;
+            }
+        }
+
+        private void ChangeMaid(int dir)
+        {
+            int selected = Utility.Wrap(
+                meidoManager.SelectedMeido + (int)Mathf.Sign(dir), 0, meidoManager.ActiveMeidoList.Count
+            );
+
+            meidoManager.ChangeMaid(selected);
+        }
+
+        private void SetEditMaid()
+        {
+            if (updating) return;
+
+            if (!editToggle.Value)
+            {
+                updating = true;
+                editToggle.Value = true;
+                updating = false;
+                return;
+            }
+
+            if (meidoManager.HasActiveMeido)
+            {
+                meidoManager.SetEditMaid(meidoManager.ActiveMeido);
+            }
+        }
+    }
+}

+ 45 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/OtherPanes/TabsPane.cs

@@ -0,0 +1,45 @@
+using System;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class TabsPane : BasePane
+    {
+        private static readonly string[] tabNames = { "call", "pose", "face", "bg", "bg2" };
+        private readonly SelectionGrid Tabs;
+        private Constants.Window selectedTab;
+        public Constants.Window SelectedTab
+        {
+            get => selectedTab;
+            set => Tabs.SelectedItemIndex = (int)value;
+        }
+        public event EventHandler TabChange;
+
+        public TabsPane()
+        {
+            Translation.ReloadTranslationEvent += (s, a) => ReloadTranslation();
+            Tabs = new SelectionGrid(Translation.GetArray("tabs", tabNames));
+            Tabs.ControlEvent += (s, a) => OnChangeTab();
+        }
+
+        protected override void ReloadTranslation()
+        {
+            updating = true;
+            Tabs.SetItems(Translation.GetArray("tabs", tabNames), Tabs.SelectedItemIndex);
+            updating = false;
+        }
+
+        private void OnChangeTab()
+        {
+            if (updating) return;
+            selectedTab = (Constants.Window)Tabs.SelectedItemIndex;
+            TabChange?.Invoke(null, EventArgs.Empty);
+        }
+
+        public override void Draw()
+        {
+            Tabs.Draw(GUILayout.ExpandWidth(false));
+            MpsGui.BlackLine();
+        }
+    }
+}

+ 80 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/PoseWindowPanes/CopyPosePane.cs

@@ -0,0 +1,80 @@
+using System.Linq;
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class CopyPosePane : BasePane
+    {
+        private readonly MeidoManager meidoManager;
+        private readonly Button copyButton;
+        private readonly Dropdown meidoDropdown;
+        private int[] copyMeidoSlot;
+        private bool PlentyOfMaids => meidoManager.ActiveMeidoList.Count >= 2;
+        private Meido FromMeido => meidoManager.HasActiveMeido
+            ? meidoManager.ActiveMeidoList[copyMeidoSlot[meidoDropdown.SelectedItemIndex]]
+            : null;
+        private string copyIKHeader;
+
+        public CopyPosePane(MeidoManager meidoManager)
+        {
+            this.meidoManager = meidoManager;
+
+            meidoDropdown = new Dropdown(new[] { Translation.Get("systemMessage", "noMaids") });
+
+            copyButton = new Button(Translation.Get("copyPosePane", "copyButton"));
+            copyButton.ControlEvent += (s, a) => CopyPose();
+
+            copyIKHeader = Translation.Get("copyPosePane", "header");
+        }
+
+        protected override void ReloadTranslation()
+        {
+            if (!PlentyOfMaids)
+            {
+                meidoDropdown.SetDropdownItem(0, Translation.Get("systemMessage", "noMaids"));
+            }
+            copyButton.Label = Translation.Get("copyPosePane", "copyButton");
+            copyIKHeader = Translation.Get("copyPosePane", "header");
+        }
+
+        public override void Draw()
+        {
+            GUI.enabled = PlentyOfMaids;
+
+            MpsGui.Header(copyIKHeader);
+            MpsGui.WhiteLine();
+
+            GUILayout.BeginHorizontal();
+            meidoDropdown.Draw(GUILayout.Width(160f));
+            copyButton.Draw(GUILayout.ExpandWidth(false));
+            GUILayout.EndHorizontal();
+
+            GUI.enabled = true;
+        }
+
+        public override void UpdatePane() => SetMeidoDropdown();
+
+        private void CopyPose()
+        {
+            if (meidoManager.ActiveMeidoList.Count >= 2) meidoManager.ActiveMeido.CopyPose(FromMeido);
+        }
+
+        private void SetMeidoDropdown()
+        {
+            if (meidoManager.ActiveMeidoList.Count >= 2)
+            {
+                IEnumerable<Meido> copyMeidoList = meidoManager.ActiveMeidoList
+                    .Where(meido => meido.Slot != meidoManager.ActiveMeido.Slot);
+
+                copyMeidoSlot = copyMeidoList.Select(meido => meido.Slot).ToArray();
+
+                string[] dropdownList = copyMeidoList
+                    .Select((meido, i) => $"{copyMeidoSlot[i] + 1}: {meido.LastName} {meido.FirstName}").ToArray();
+
+                meidoDropdown.SetDropdownItems(dropdownList, 0);
+            }
+            else meidoDropdown.SetDropdownItems(new[] { Translation.Get("systemMessage", "noMaids") });
+        }
+    }
+}

+ 97 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/PoseWindowPanes/GravityControlPane.cs

@@ -0,0 +1,97 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class GravityControlPane : BasePane
+    {
+        private readonly MeidoManager meidoManager;
+        private readonly Toggle hairToggle;
+        private readonly Toggle skirtToggle;
+        private readonly Toggle globalToggle;
+        private string header;
+
+        public GravityControlPane(MeidoManager meidoManager)
+        {
+            this.meidoManager = meidoManager;
+
+            hairToggle = new Toggle(Translation.Get("gravityControlPane", "hairToggle"));
+            hairToggle.ControlEvent += (s, a) => ToggleGravity(hairToggle.Value, skirt: false);
+
+            skirtToggle = new Toggle(Translation.Get("gravityControlPane", "skirtToggle"));
+            skirtToggle.ControlEvent += (s, a) => ToggleGravity(skirtToggle.Value, skirt: true);
+
+            globalToggle = new Toggle(Translation.Get("gravityControlPane", "globalToggle"));
+            globalToggle.ControlEvent += (s, a) => SetGlobalGravity(globalToggle.Value);
+
+            header = Translation.Get("gravityControlPane", "gravityHeader");
+        }
+
+        protected override void ReloadTranslation()
+        {
+            hairToggle.Label = Translation.Get("gravityControlPane", "hairToggle");
+            skirtToggle.Label = Translation.Get("gravityControlPane", "skirtToggle");
+            globalToggle.Label = Translation.Get("gravityControlPane", "globalToggle");
+            header = Translation.Get("gravityControlPane", "gravityHeader");
+        }
+
+        public override void Draw()
+        {
+            bool enabled = meidoManager.HasActiveMeido;
+            GUI.enabled = enabled;
+
+            MpsGui.Header(header);
+            MpsGui.WhiteLine();
+
+            Meido meido = meidoManager.ActiveMeido;
+            GUILayout.BeginHorizontal();
+
+            GUI.enabled = enabled && meido.HairGravityControl.Valid;
+            hairToggle.Draw();
+
+            GUI.enabled = enabled && meido.SkirtGravityControl.Valid;
+            skirtToggle.Draw();
+
+            GUILayout.EndHorizontal();
+
+            GUI.enabled = enabled;
+            globalToggle.Draw();
+
+            GUI.enabled = true;
+        }
+
+        public override void UpdatePane()
+        {
+            if (!meidoManager.HasActiveMeido) return;
+
+            Meido meido = meidoManager.ActiveMeido;
+
+            updating = true;
+
+            hairToggle.Value = meido.HairGravityActive;
+            skirtToggle.Value = meido.SkirtGravityActive;
+
+            updating = false;
+        }
+
+        private void ToggleGravity(bool value, bool skirt = false)
+        {
+            if (updating) return;
+
+            if (meidoManager.GlobalGravity)
+            {
+                foreach (Meido meido in meidoManager.ActiveMeidoList)
+                {
+                    if (skirt) meido.SkirtGravityActive = value;
+                    else meido.HairGravityActive = value;
+                }
+            }
+            else
+            {
+                if (skirt) meidoManager.ActiveMeido.SkirtGravityActive = value;
+                else meidoManager.ActiveMeido.HairGravityActive = value;
+            }
+        }
+
+        private void SetGlobalGravity(bool value) => meidoManager.GlobalGravity = value;
+    }
+}

+ 134 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/PoseWindowPanes/HandPresetPane.cs

@@ -0,0 +1,134 @@
+using System.IO;
+using System.Collections.Generic;
+using System.Linq;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class HandPresetPane : BasePane
+    {
+        private readonly MeidoManager meidoManager;
+        private readonly Dropdown presetCategoryDropdown;
+        private readonly Button nextCategoryButton;
+        private readonly Button previousCategoryButton;
+        private readonly Dropdown presetDropdown;
+        private readonly Button nextPresetButton;
+        private readonly Button previousPresetButton;
+        private readonly Button leftHandButton;
+        private readonly Button rightHandButton;
+        private string SelectedCategory => Constants.CustomHandGroupList[presetCategoryDropdown.SelectedItemIndex];
+        private List<string> CurrentPresetList => Constants.CustomHandDict[SelectedCategory];
+        private string CurrentPreset => CurrentPresetList[presetDropdown.SelectedItemIndex];
+        private string previousCategory;
+        private bool presetListEnabled = true;
+
+        public HandPresetPane(MeidoManager meidoManager)
+        {
+            Constants.CustomHandChange += OnPresetChange;
+            this.meidoManager = meidoManager;
+
+            presetCategoryDropdown = new Dropdown(Constants.CustomHandGroupList.ToArray());
+            presetCategoryDropdown.SelectionChange += (s, a) => ChangePresetCategory();
+
+            nextCategoryButton = new Button(">");
+            nextCategoryButton.ControlEvent += (s, a) => presetCategoryDropdown.Step(1);
+
+            previousCategoryButton = new Button("<");
+            previousCategoryButton.ControlEvent += (s, a) => presetCategoryDropdown.Step(-1);
+
+            presetDropdown = new Dropdown(UIPresetList());
+
+            nextPresetButton = new Button(">");
+            nextPresetButton.ControlEvent += (s, a) => presetDropdown.Step(1);
+
+            previousPresetButton = new Button("<");
+            previousPresetButton.ControlEvent += (s, a) => presetDropdown.Step(-1);
+
+            leftHandButton = new Button(Translation.Get("handPane", "leftHand"));
+            leftHandButton.ControlEvent += (s, a) => SetHandPreset(right: false);
+
+            rightHandButton = new Button(Translation.Get("handPane", "rightHand"));
+            rightHandButton.ControlEvent += (s, a) => SetHandPreset(right: true);
+
+            previousCategory = SelectedCategory;
+            presetListEnabled = CurrentPresetList.Count > 0;
+        }
+
+        protected override void ReloadTranslation()
+        {
+            leftHandButton.Label = Translation.Get("handPane", "leftHand");
+            rightHandButton.Label = Translation.Get("handPane", "rightHand");
+            if (CurrentPresetList.Count == 0) presetDropdown.SetDropdownItems(UIPresetList());
+        }
+
+        public override void Draw()
+        {
+            GUILayoutOption dropdownWidth = GUILayout.Width(156f);
+            GUILayoutOption noExpandWidth = GUILayout.ExpandWidth(false);
+
+            GUI.enabled = meidoManager.HasActiveMeido;
+
+            GUILayout.BeginHorizontal();
+            presetCategoryDropdown.Draw(dropdownWidth);
+            previousCategoryButton.Draw(noExpandWidth);
+            nextCategoryButton.Draw(noExpandWidth);
+            GUILayout.EndHorizontal();
+
+            GUI.enabled = GUI.enabled && presetListEnabled;
+
+            GUILayout.BeginHorizontal();
+            presetDropdown.Draw(dropdownWidth);
+            previousPresetButton.Draw(noExpandWidth);
+            nextPresetButton.Draw(noExpandWidth);
+            GUILayout.EndHorizontal();
+
+            GUILayout.BeginHorizontal();
+            rightHandButton.Draw();
+            leftHandButton.Draw();
+            GUILayout.EndHorizontal();
+
+            GUI.enabled = true;
+        }
+
+        private void ChangePresetCategory()
+        {
+            presetListEnabled = CurrentPresetList.Count > 0;
+            if (previousCategory == SelectedCategory) presetDropdown.SelectedItemIndex = 0;
+            else
+            {
+                previousCategory = SelectedCategory;
+                presetDropdown.SetDropdownItems(UIPresetList(), 0);
+            }
+        }
+
+        private void SetHandPreset(bool right = false)
+        {
+            if (!meidoManager.HasActiveMeido) return;
+
+            meidoManager.ActiveMeido.SetHandPreset(CurrentPreset, right);
+        }
+
+        private void OnPresetChange(object sender, PresetChangeEventArgs args)
+        {
+            if (args == PresetChangeEventArgs.Empty)
+            {
+                presetCategoryDropdown.SetDropdownItems(Constants.CustomHandGroupList.ToArray(), 0);
+                presetDropdown.SetDropdownItems(UIPresetList(), 0);
+            }
+            else
+            {
+                presetCategoryDropdown.SetDropdownItems(
+                    Constants.CustomHandGroupList.ToArray(), Constants.CustomHandGroupList.IndexOf(args.Category)
+                );
+                presetDropdown.SetDropdownItems(UIPresetList(), CurrentPresetList.IndexOf(args.Path));
+            }
+        }
+
+        private string[] UIPresetList()
+        {
+            return CurrentPresetList.Count == 0
+                ? new[] { Translation.Get("handPane", "noPresetsMessage") }
+                : CurrentPresetList.Select(Path.GetFileNameWithoutExtension).ToArray();
+        }
+    }
+}

+ 289 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/PoseWindowPanes/MaidDressingPane.cs

@@ -0,0 +1,289 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+using static TBody;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    using static Meido;
+    public class MaidDressingPane : BasePane
+    {
+        public static readonly SlotID[] ClothingSlots =
+        {
+            // main slots
+            SlotID.wear, SlotID.skirt, SlotID.bra, SlotID.panz, SlotID.headset, SlotID.megane, SlotID.accUde,
+            SlotID.glove, SlotID.accSenaka, SlotID.stkg, SlotID.shoes, SlotID.body,
+            // detailed slots
+            SlotID.accAshi, SlotID.accHana, SlotID.accHat, SlotID.accHeso, SlotID.accKamiSubL, SlotID.accKamiSubR,
+            SlotID.accKami_1_, SlotID.accKami_2_, SlotID.accKami_3_, SlotID.accKubi, SlotID.accKubiwa, SlotID.accMiMiL,
+            SlotID.accMiMiR, SlotID.accNipL, SlotID.accNipR, SlotID.accShippo, SlotID.accXXX
+            // unused slots
+            // SlotID.mizugi, SlotID.onepiece, SlotID.accHead,
+        };
+
+        public static readonly SlotID[] BodySlots =
+        {
+            SlotID.body, SlotID.head, SlotID.eye, SlotID.hairF, SlotID.hairR, SlotID.hairS, SlotID.hairT,
+            SlotID.hairAho, SlotID.chikubi, SlotID.underhair, SlotID.moza, SlotID.accHa
+        };
+
+        public static readonly SlotID[] WearSlots = { SlotID.wear, SlotID.mizugi, SlotID.onepiece };
+
+        public static readonly SlotID[] HeadwearSlots =
+        {
+            SlotID.headset, SlotID.accHat, SlotID.accKamiSubL, SlotID.accKamiSubR, SlotID.accKami_1_,
+            SlotID.accKami_2_, SlotID.accKami_3_
+        };
+
+        private readonly MeidoManager meidoManager;
+        private readonly Dictionary<SlotID, Toggle> clothingToggles;
+        private readonly Dictionary<SlotID, bool> loadedSlots;
+        private readonly Toggle detailedClothingToggle;
+        private readonly SelectionGrid maskModeGrid;
+        private readonly Toggle curlingFrontToggle;
+        private readonly Toggle curlingBackToggle;
+        private readonly Toggle pantsuShiftToggle;
+        private bool detailedClothing;
+        private static readonly string[] maskLabels = { "all", "underwear", "nude" };
+
+        public MaidDressingPane(MeidoManager meidoManager)
+        {
+            this.meidoManager = meidoManager;
+
+            clothingToggles = new Dictionary<SlotID, Toggle>(ClothingSlots.Length);
+            loadedSlots = new Dictionary<SlotID, bool>(ClothingSlots.Length);
+            foreach (SlotID slot in ClothingSlots)
+            {
+                var slotToggle = new Toggle(Translation.Get("clothing", slot.ToString()));
+                slotToggle.ControlEvent += (s, a) => ToggleClothing(slot, slotToggle.Value);
+                clothingToggles.Add(slot, slotToggle);
+                loadedSlots[slot] = true;
+            }
+
+            detailedClothingToggle = new Toggle(Translation.Get("clothing", "detail"));
+            detailedClothingToggle.ControlEvent += (s, a) => UpdateDetailedClothing();
+
+            curlingFrontToggle = new Toggle(Translation.Get("clothing", "curlingFront"));
+            curlingFrontToggle.ControlEvent += (s, a) => ToggleCurling(Curl.Front, curlingFrontToggle.Value);
+            curlingBackToggle = new Toggle(Translation.Get("clothing", "curlingBack"));
+            curlingBackToggle.ControlEvent += (s, a) => ToggleCurling(Curl.Back, curlingBackToggle.Value);
+            pantsuShiftToggle = new Toggle(Translation.Get("clothing", "shiftPanties"));
+            pantsuShiftToggle.ControlEvent += (s, a) => ToggleCurling(Curl.Shift, pantsuShiftToggle.Value);
+
+            maskModeGrid = new SelectionGrid(Translation.GetArray("clothing", maskLabels));
+            maskModeGrid.ControlEvent += (s, a) => SetMaskMode((Mask)maskModeGrid.SelectedItemIndex);
+
+            UpdateDetailedClothing();
+        }
+
+        protected override void ReloadTranslation()
+        {
+            foreach (SlotID slot in ClothingSlots)
+            {
+                Toggle clothingToggle = clothingToggles[slot];
+                if (slot == SlotID.headset)
+                {
+                    clothingToggle.Label = detailedClothing
+                        ? Translation.Get("clothing", "headset")
+                        : Translation.Get("clothing", "headwear");
+                }
+                else clothingToggle.Label = Translation.Get("clothing", slot.ToString());
+            }
+
+            updating = true;
+            maskModeGrid.SetItems(Translation.GetArray("clothing", maskLabels));
+            updating = false;
+
+            detailedClothingToggle.Label = Translation.Get("clothing", "detail");
+            curlingFrontToggle.Label = Translation.Get("clothing", "curlingFront");
+            curlingBackToggle.Label = Translation.Get("clothing", "curlingBack");
+            pantsuShiftToggle.Label = Translation.Get("clothing", "shiftPanties");
+        }
+
+        private void ToggleClothing(SlotID slot, bool enabled)
+        {
+            if (updating) return;
+
+            if (slot == SlotID.body)
+            {
+                meidoManager.ActiveMeido.SetBodyMask(enabled);
+                return;
+            }
+
+            TBody body = meidoManager.ActiveMeido.Maid.body0;
+
+            if (!detailedClothing && slot == SlotID.headset)
+            {
+                updating = true;
+                foreach (SlotID wearSlot in HeadwearSlots)
+                {
+                    body.SetMask(wearSlot, enabled);
+                    clothingToggles[wearSlot].Value = enabled;
+                }
+                updating = false;
+            }
+            else
+            {
+                if (slot == SlotID.wear)
+                {
+                    foreach (SlotID wearSlot in WearSlots) body.SetMask(wearSlot, enabled);
+                }
+                else if (slot == SlotID.megane)
+                {
+                    body.SetMask(SlotID.megane, enabled);
+                    body.SetMask(SlotID.accHead, enabled);
+                }
+                else body.SetMask(slot, enabled);
+            }
+        }
+
+        private void ToggleCurling(Curl curl, bool enabled)
+        {
+            if (updating) return;
+
+            meidoManager.ActiveMeido.SetCurling(curl, enabled);
+
+            if (!enabled) return;
+
+            updating = true;
+            if (curl == Curl.Front && curlingBackToggle.Value) curlingBackToggle.Value = false;
+            else if (curl == Curl.Back && curlingFrontToggle.Value) curlingFrontToggle.Value = false;
+
+            updating = false;
+        }
+
+        private void SetMaskMode(Mask mask)
+        {
+            if (updating) return;
+
+            meidoManager.ActiveMeido.SetMaskMode(mask);
+
+            UpdatePane();
+        }
+
+        public override void UpdatePane()
+        {
+            if (!meidoManager.HasActiveMeido) return;
+
+            updating = true;
+
+            Meido meido = meidoManager.ActiveMeido;
+            TBody body = meido.Maid.body0;
+
+            foreach (SlotID clothingSlot in ClothingSlots)
+            {
+                var toggleValue = false;
+                var hasSlot = false;
+                if (clothingSlot == SlotID.wear)
+                {
+                    foreach (SlotID wearSlot in WearSlots)
+                    {
+                        if (body.GetMask(wearSlot)) toggleValue = true;
+                        if (body.GetSlotLoaded(wearSlot)) hasSlot = true;
+                        if (hasSlot && toggleValue) break;
+                    }
+                }
+                else if (clothingSlot == SlotID.megane)
+                {
+                    toggleValue = body.GetMask(SlotID.megane) || body.GetMask(SlotID.accHead);
+                    hasSlot = body.GetSlotLoaded(SlotID.megane) || body.GetSlotLoaded(SlotID.accHead);
+                }
+                else if (!detailedClothing && clothingSlot == SlotID.headset)
+                {
+                    foreach (SlotID headwearSlot in HeadwearSlots)
+                    {
+                        if (body.GetMask(headwearSlot)) toggleValue = true;
+                        if (body.GetSlotLoaded(headwearSlot)) hasSlot = true;
+                        if (hasSlot && toggleValue) break;
+                    }
+                }
+                else
+                {
+                    toggleValue = body.GetMask(clothingSlot);
+                    hasSlot = body.GetSlotLoaded(clothingSlot);
+                }
+
+                clothingToggles[clothingSlot].Value = hasSlot && toggleValue;
+                loadedSlots[clothingSlot] = hasSlot;
+            }
+
+            curlingFrontToggle.Value = meido.CurlingFront;
+            curlingBackToggle.Value = meido.CurlingBack;
+            pantsuShiftToggle.Value = meido.PantsuShift;
+
+            MaskMode maskMode = meido.CurrentMaskMode;
+
+            maskModeGrid.SelectedItemIndex = maskMode == MaskMode.Nude ? (int)Mask.Nude : (int)maskMode;
+
+            updating = false;
+        }
+
+        private void DrawSlotGroup(params SlotID[] slots)
+        {
+            GUILayout.BeginHorizontal();
+            for (var i = 0; i < slots.Length; i++)
+            {
+                SlotID slot = slots[i];
+                GUI.enabled = Enabled && loadedSlots[slot];
+                clothingToggles[slot].Draw();
+                if (i < slots.Length - 1) GUILayout.FlexibleSpace();
+            }
+            GUILayout.EndHorizontal();
+
+            GUI.enabled = Enabled;
+        }
+
+        private void UpdateDetailedClothing()
+        {
+            detailedClothing = detailedClothingToggle.Value;
+            clothingToggles[SlotID.headset].Label = detailedClothing
+                ? Translation.Get("clothing", "headset")
+                : Translation.Get("clothing", "headwear");
+            UpdatePane();
+        }
+
+        public override void Draw()
+        {
+            GUI.enabled = Enabled = meidoManager.HasActiveMeido;
+
+            detailedClothingToggle.Draw();
+
+            MpsGui.BlackLine();
+
+            maskModeGrid.Draw();
+
+            MpsGui.BlackLine();
+
+            DrawSlotGroup(SlotID.wear, SlotID.skirt);
+            DrawSlotGroup(SlotID.bra, SlotID.panz);
+            DrawSlotGroup(SlotID.headset, SlotID.megane);
+            DrawSlotGroup(SlotID.accUde, SlotID.glove, SlotID.accSenaka);
+            DrawSlotGroup(SlotID.stkg, SlotID.shoes, SlotID.body);
+
+            if (detailedClothing)
+            {
+                MpsGui.BlackLine();
+                DrawSlotGroup(SlotID.accShippo, SlotID.accHat);
+                DrawSlotGroup(SlotID.accKami_1_, SlotID.accKami_2_, SlotID.accKami_3_);
+                DrawSlotGroup(SlotID.accKamiSubL, SlotID.accKamiSubR);
+                DrawSlotGroup(SlotID.accMiMiL, SlotID.accMiMiR);
+                DrawSlotGroup(SlotID.accNipL, SlotID.accNipR);
+                DrawSlotGroup(SlotID.accHana, SlotID.accKubi, SlotID.accKubiwa);
+                DrawSlotGroup(SlotID.accHeso, SlotID.accAshi, SlotID.accXXX);
+            }
+
+            MpsGui.BlackLine();
+
+            GUILayout.BeginHorizontal();
+            curlingFrontToggle.Draw();
+            GUILayout.FlexibleSpace();
+            curlingBackToggle.Draw();
+            GUILayout.FlexibleSpace();
+            pantsuShiftToggle.Draw();
+            GUILayout.FlexibleSpace();
+            GUILayout.EndHorizontal();
+
+            GUI.enabled = true;
+        }
+    }
+}

+ 102 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/PoseWindowPanes/MaidFreeLookPane.cs

@@ -0,0 +1,102 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class MaidFaceLookPane : BasePane
+    {
+        private readonly MeidoManager meidoManager;
+        private readonly Slider lookXSlider;
+        private readonly Slider lookYSlider;
+        private readonly Toggle headToCamToggle;
+        private readonly Toggle eyeToCamToggle;
+        private string bindLabel;
+
+        public MaidFaceLookPane(MeidoManager meidoManager)
+        {
+            this.meidoManager = meidoManager;
+            lookXSlider = new Slider(Translation.Get("freeLookPane", "xSlider"), -0.6f, 0.6f);
+            lookXSlider.ControlEvent += (s, a) => SetMaidLook();
+
+            lookYSlider = new Slider(Translation.Get("freeLookPane", "ySlider"), 0.5f, -0.55f);
+            lookYSlider.ControlEvent += (s, a) => SetMaidLook();
+
+            headToCamToggle = new Toggle(Translation.Get("freeLookPane", "headToCamToggle"));
+            headToCamToggle.ControlEvent += (s, a) => SetHeadToCam(headToCamToggle.Value, eye: false);
+
+            eyeToCamToggle = new Toggle(Translation.Get("freeLookPane", "eyeToCamToggle"));
+            eyeToCamToggle.ControlEvent += (s, a) => SetHeadToCam(eyeToCamToggle.Value, eye: true);
+
+            bindLabel = Translation.Get("freeLookPane", "bindLabel");
+        }
+
+        protected override void ReloadTranslation()
+        {
+            lookXSlider.Label = Translation.Get("freeLookPane", "xSlider");
+            lookYSlider.Label = Translation.Get("freeLookPane", "ySlider");
+            headToCamToggle.Label = Translation.Get("freeLookPane", "headToCamToggle");
+            eyeToCamToggle.Label = Translation.Get("freeLookPane", "eyeToCamToggle");
+            bindLabel = Translation.Get("freeLookPane", "bindLabel");
+        }
+
+        public void SetHeadToCam(bool value, bool eye = false)
+        {
+            if (updating) return;
+
+            Meido meido = meidoManager.ActiveMeido;
+
+            if (eye) meido.EyeToCam = value;
+            else meido.HeadToCam = value;
+        }
+
+        public void SetMaidLook()
+        {
+            if (updating) return;
+
+            TBody body = meidoManager.ActiveMeido.Body;
+            body.offsetLookTarget = new Vector3(lookYSlider.Value, 1f, lookXSlider.Value);
+        }
+
+        public void SetBounds()
+        {
+            float left = 0.5f;
+            float right = -0.55f;
+            if (meidoManager.ActiveMeido.Stop)
+            {
+                left *= 0.6f;
+                right *= 0.6f;
+            }
+            lookYSlider.SetBounds(left, right);
+        }
+
+        public override void UpdatePane()
+        {
+            Meido meido = meidoManager.ActiveMeido;
+            updating = true;
+            SetBounds();
+            lookXSlider.Value = meido.Body.offsetLookTarget.z;
+            lookYSlider.Value = meido.Body.offsetLookTarget.x;
+            eyeToCamToggle.Value = meido.EyeToCam;
+            headToCamToggle.Value = meido.HeadToCam;
+            updating = false;
+        }
+
+        public override void Draw()
+        {
+            GUI.enabled = meidoManager.HasActiveMeido && meidoManager.ActiveMeido.FreeLook;
+            GUILayout.BeginHorizontal();
+            lookXSlider.Draw();
+            lookYSlider.Draw();
+            GUILayout.EndHorizontal();
+
+            GUI.enabled = meidoManager.HasActiveMeido;
+
+            GUILayout.BeginHorizontal();
+            GUILayout.Label(bindLabel, GUILayout.ExpandWidth(false));
+            eyeToCamToggle.Draw();
+            headToCamToggle.Draw();
+            GUILayout.EndHorizontal();
+
+            GUI.enabled = true;
+        }
+    }
+}

+ 71 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/PoseWindowPanes/MaidIKPane.cs

@@ -0,0 +1,71 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class MaidIKPane : BasePane
+    {
+        private readonly MeidoManager meidoManager;
+        private readonly Toggle ikToggle;
+        private readonly Toggle releaseIKToggle;
+        private readonly Toggle boneIKToggle;
+        private enum IKToggle
+        {
+            IK, Release, Bone
+        }
+
+        public MaidIKPane(MeidoManager meidoManager)
+        {
+            this.meidoManager = meidoManager;
+
+            ikToggle = new Toggle(Translation.Get("maidPoseWindow", "ikToggle"), true);
+            ikToggle.ControlEvent += (s, a) => SetIK(IKToggle.IK, ikToggle.Value);
+
+            releaseIKToggle = new Toggle(Translation.Get("maidPoseWindow", "releaseToggle"));
+            releaseIKToggle.ControlEvent += (s, a) => SetIK(IKToggle.Release, releaseIKToggle.Value);
+
+            boneIKToggle = new Toggle(Translation.Get("maidPoseWindow", "boneToggle"));
+            boneIKToggle.ControlEvent += (s, a) => SetIK(IKToggle.Bone, boneIKToggle.Value);
+        }
+
+        protected override void ReloadTranslation()
+        {
+            ikToggle.Label = Translation.Get("maidPoseWindow", "ikToggle");
+            releaseIKToggle.Label = Translation.Get("maidPoseWindow", "releaseToggle");
+            boneIKToggle.Label = Translation.Get("maidPoseWindow", "boneToggle");
+        }
+
+        private void SetIK(IKToggle toggle, bool value)
+        {
+            if (updating) return;
+            if (toggle == IKToggle.IK) meidoManager.ActiveMeido.IK = value;
+            else if (toggle == IKToggle.Release) meidoManager.ActiveMeido.Stop = false;
+            else if (toggle == IKToggle.Bone) meidoManager.ActiveMeido.Bone = value;
+        }
+
+        public override void UpdatePane()
+        {
+            updating = true;
+            ikToggle.Value = meidoManager.ActiveMeido.IK;
+            releaseIKToggle.Value = meidoManager.ActiveMeido.Stop;
+            boneIKToggle.Value = meidoManager.ActiveMeido.Bone;
+            updating = false;
+        }
+
+        public override void Draw()
+        {
+            bool active = meidoManager.HasActiveMeido;
+
+            GUILayout.BeginHorizontal();
+            GUI.enabled = active;
+            ikToggle.Draw();
+
+            GUI.enabled = active && meidoManager.ActiveMeido.Stop;
+            releaseIKToggle.Draw();
+
+            GUI.enabled = active && ikToggle.Value;
+            boneIKToggle.Draw();
+            GUILayout.EndHorizontal();
+            GUI.enabled = true;
+        }
+    }
+}

+ 212 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/PoseWindowPanes/MaidPoseSelectorPane.cs

@@ -0,0 +1,212 @@
+using System.IO;
+using System.Collections.Generic;
+using System.Linq;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class MaidPoseSelectorPane : BasePane
+    {
+        private static readonly string[] tabTranslations = new[] { "baseTab", "customTab" };
+        private readonly MeidoManager meidoManager;
+        private readonly Button poseLeftButton;
+        private readonly Button poseRightButton;
+        private readonly Button poseGroupLeftButton;
+        private readonly Button poseGroupRightButton;
+        private readonly Dropdown poseGroupDropdown;
+        private readonly Dropdown poseDropdown;
+        private readonly SelectionGrid poseModeGrid;
+        private Dictionary<string, List<string>> CurrentPoseDict
+            => customPoseMode ? Constants.CustomPoseDict : Constants.PoseDict;
+        private List<string> CurrentPoseGroupList
+            => customPoseMode ? Constants.CustomPoseGroupList : Constants.PoseGroupList;
+        private string SelectedPoseGroup => CurrentPoseGroupList[poseGroupDropdown.SelectedItemIndex];
+        private List<string> CurrentPoseList => CurrentPoseDict[SelectedPoseGroup];
+        private int SelectedPoseIndex => poseDropdown.SelectedItemIndex;
+        private string SelectedPose => CurrentPoseList[SelectedPoseIndex];
+        private PoseInfo CurrentPoseInfo => new PoseInfo(SelectedPoseGroup, SelectedPose, customPoseMode);
+        private bool customPoseMode;
+        private bool poseListEnabled;
+        private string previousPoseGroup;
+
+        public MaidPoseSelectorPane(MeidoManager meidoManager)
+        {
+            Constants.CustomPoseChange += OnPresetChange;
+            this.meidoManager = meidoManager;
+
+            poseModeGrid = new SelectionGrid(Translation.GetArray("posePane", tabTranslations));
+            poseModeGrid.ControlEvent += (s, a) => SetPoseMode();
+
+            poseGroupDropdown = new Dropdown(Translation.GetArray("poseGroupDropdown", Constants.PoseGroupList));
+            poseGroupDropdown.SelectionChange += (s, a) => ChangePoseGroup();
+
+            poseDropdown = new Dropdown(UIPoseList());
+            poseDropdown.SelectionChange += (s, a) => ChangePose();
+
+            poseGroupLeftButton = new Button("<");
+            poseGroupLeftButton.ControlEvent += (s, a) => poseGroupDropdown.Step(-1);
+
+            poseGroupRightButton = new Button(">");
+            poseGroupRightButton.ControlEvent += (s, a) => poseGroupDropdown.Step(1);
+
+            poseLeftButton = new Button("<");
+            poseLeftButton.ControlEvent += (s, a) => poseDropdown.Step(-1);
+
+            poseRightButton = new Button(">");
+            poseRightButton.ControlEvent += (s, a) => poseDropdown.Step(1);
+
+            customPoseMode = poseModeGrid.SelectedItemIndex == 1;
+            previousPoseGroup = SelectedPoseGroup;
+            poseListEnabled = CurrentPoseList.Count > 0;
+        }
+
+        protected override void ReloadTranslation()
+        {
+            updating = true;
+            poseModeGrid.SetItems(Translation.GetArray("posePane", tabTranslations));
+            if (!customPoseMode)
+            {
+                poseGroupDropdown.SetDropdownItems(
+                    Translation.GetArray("poseGroupDropdown", Constants.PoseGroupList)
+                );
+            }
+            updating = false;
+        }
+
+        public override void Draw()
+        {
+            const float buttonHeight = 30f;
+            GUILayoutOption[] arrowLayoutOptions = {
+                GUILayout.Width(buttonHeight),
+                GUILayout.Height(buttonHeight)
+            };
+
+            const float dropdownButtonWidth = 153f;
+            GUILayoutOption[] dropdownLayoutOptions = new GUILayoutOption[] {
+                GUILayout.Height(buttonHeight),
+                GUILayout.Width(dropdownButtonWidth)
+            };
+
+            GUI.enabled = meidoManager.HasActiveMeido && !meidoManager.ActiveMeido.Stop;
+
+            poseModeGrid.Draw();
+            MpsGui.WhiteLine();
+
+            GUILayout.BeginHorizontal();
+            poseGroupLeftButton.Draw(arrowLayoutOptions);
+            poseGroupDropdown.Draw(dropdownLayoutOptions);
+            poseGroupRightButton.Draw(arrowLayoutOptions);
+            GUILayout.EndHorizontal();
+
+            GUILayout.BeginHorizontal();
+            GUI.enabled = GUI.enabled && poseListEnabled;
+            poseLeftButton.Draw(arrowLayoutOptions);
+            poseDropdown.Draw(dropdownLayoutOptions);
+            poseRightButton.Draw(arrowLayoutOptions);
+            GUILayout.EndHorizontal();
+
+            GUI.enabled = true;
+        }
+
+        public override void UpdatePane()
+        {
+            updating = true;
+
+            PoseInfo poseInfo = meidoManager.ActiveMeido.CachedPose;
+
+            bool oldPoseMode = customPoseMode;
+
+            poseModeGrid.SelectedItemIndex = poseInfo.CustomPose ? 1 : 0;
+
+            int poseGroupIndex = CurrentPoseGroupList.IndexOf(poseInfo.PoseGroup);
+
+            if (poseGroupIndex < 0) poseGroupIndex = 0;
+
+            int poseIndex = CurrentPoseDict[poseInfo.PoseGroup].IndexOf(poseInfo.Pose);
+
+            if (poseIndex < 0) poseIndex = 0;
+
+            if (oldPoseMode != customPoseMode)
+            {
+                string[] list = customPoseMode
+                    ? CurrentPoseGroupList.ToArray()
+                    : Translation.GetArray("poseGroupDropdown", CurrentPoseGroupList);
+
+                poseGroupDropdown.SetDropdownItems(list);
+            }
+
+            poseGroupDropdown.SelectedItemIndex = poseGroupIndex;
+            poseDropdown.SelectedItemIndex = poseIndex;
+
+            updating = false;
+        }
+
+        private void OnPresetChange(object sender, PresetChangeEventArgs args)
+        {
+            if (args == PresetChangeEventArgs.Empty)
+            {
+                if (poseModeGrid.SelectedItemIndex == 1)
+                {
+                    updating = true;
+                    poseGroupDropdown.SetDropdownItems(CurrentPoseGroupList.ToArray(), 0);
+                    poseDropdown.SetDropdownItems(UIPoseList(), 0);
+                    updating = false;
+                }
+            }
+            else
+            {
+                updating = true;
+                poseModeGrid.SelectedItemIndex = 1;
+                poseGroupDropdown.SetDropdownItems(
+                    CurrentPoseGroupList.ToArray(), CurrentPoseGroupList.IndexOf(args.Category)
+                );
+                updating = false;
+
+                poseDropdown.SetDropdownItems(UIPoseList(), CurrentPoseList.IndexOf(args.Path));
+                poseListEnabled = true;
+            }
+        }
+
+        private void SetPoseMode()
+        {
+            customPoseMode = poseModeGrid.SelectedItemIndex == 1;
+
+            if (updating) return;
+
+            string[] list = customPoseMode
+                ? CurrentPoseGroupList.ToArray()
+                : Translation.GetArray("poseGroupDropdown", CurrentPoseGroupList);
+
+            poseGroupDropdown.SetDropdownItems(list, 0);
+        }
+
+        private void ChangePoseGroup()
+        {
+            poseListEnabled = CurrentPoseList.Count > 0;
+            if (previousPoseGroup == SelectedPoseGroup)
+            {
+                poseDropdown.SelectedItemIndex = 0;
+            }
+            else
+            {
+                previousPoseGroup = SelectedPoseGroup;
+                poseDropdown.SetDropdownItems(UIPoseList(), 0);
+            }
+        }
+
+        private void ChangePose()
+        {
+            if (!poseListEnabled || updating) return;
+            meidoManager.ActiveMeido.SetPose(CurrentPoseInfo);
+        }
+
+        private string[] UIPoseList()
+        {
+            return CurrentPoseList.Count == 0
+                ? new[] { "No Poses" }
+                : CurrentPoseList
+                    .Select((pose, i) => $"{i + 1}:{(customPoseMode ? Path.GetFileNameWithoutExtension(pose) : pose)}")
+                    .ToArray();
+        }
+    }
+}

+ 111 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/PoseWindowPanes/MpnAttachPropPane.cs

@@ -0,0 +1,111 @@
+using System.Linq;
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class MpnAttachPropPane : BasePane
+    {
+        private readonly MeidoManager meidoManager;
+        private readonly Dropdown mpnAttachDropdown;
+        private readonly Button previousPropButton;
+        private readonly Button nextPropButton;
+        private readonly Button attachPropButton;
+        private readonly Button detachPropButton;
+        private readonly Button detachAllButton;
+        private string header;
+
+        public MpnAttachPropPane(MeidoManager meidoManager)
+        {
+            this.meidoManager = meidoManager;
+
+            mpnAttachDropdown = new Dropdown(new[] { string.Empty });
+
+            if (!Constants.MpnAttachInitialized) Constants.MenuFilesChange += InitializeMpnAttach;
+
+            SetDropdownList();
+
+            previousPropButton = new Button("<");
+            previousPropButton.ControlEvent += (s, a) => mpnAttachDropdown.Step(-1);
+
+            nextPropButton = new Button(">");
+            nextPropButton.ControlEvent += (s, a) => mpnAttachDropdown.Step(1);
+
+            attachPropButton = new Button(Translation.Get("attachMpnPropPane", "attachButton"));
+            attachPropButton.ControlEvent += (s, a) => AttachProp();
+
+            detachPropButton = new Button(Translation.Get("attachMpnPropPane", "detachButton"));
+            detachPropButton.ControlEvent += (s, a) => AttachProp(detach: true);
+
+            detachAllButton = new Button(Translation.Get("attachMpnPropPane", "detachAllButton"));
+            detachAllButton.ControlEvent += (s, a) => DetachAll();
+
+            header = Translation.Get("attachMpnPropPane", "header");
+        }
+
+        protected override void ReloadTranslation()
+        {
+            attachPropButton.Label = Translation.Get("attachMpnPropPane", "attachButton");
+            detachPropButton.Label = Translation.Get("attachMpnPropPane", "detachButton");
+            detachAllButton.Label = Translation.Get("attachMpnPropPane", "detachAllButton");
+            header = Translation.Get("attachMpnPropPane", "header");
+            SetDropdownList();
+        }
+
+        public override void Draw()
+        {
+            GUI.enabled = meidoManager.HasActiveMeido && Constants.MpnAttachInitialized;
+
+            GUILayoutOption noExpand = GUILayout.ExpandWidth(false);
+
+            MpsGui.Header(header);
+            MpsGui.WhiteLine();
+
+            GUILayout.BeginHorizontal();
+            previousPropButton.Draw(noExpand);
+            mpnAttachDropdown.Draw(GUILayout.Width(153f));
+            nextPropButton.Draw(noExpand);
+            GUILayout.EndHorizontal();
+
+            GUILayout.BeginHorizontal();
+            attachPropButton.Draw();
+            detachPropButton.Draw();
+            GUILayout.EndHorizontal();
+            detachAllButton.Draw();
+
+            GUI.enabled = true;
+        }
+
+        private void InitializeMpnAttach(object sender, MenuFilesEventArgs args)
+        {
+            if (args.Type == MenuFilesEventArgs.EventType.MpnAttach) SetDropdownList();
+        }
+
+        private void SetDropdownList()
+        {
+            IEnumerable<string> dropdownList = !Constants.MpnAttachInitialized
+                ? new[] { Translation.Get("systemMessage", "initializing") }
+                : Translation.GetArray(
+                    "mpnAttachPropNames", Constants.MpnAttachPropList.Select(mpnProp => mpnProp.MenuFile)
+                );
+
+            mpnAttachDropdown.SetDropdownItems(dropdownList.ToArray());
+        }
+
+        private void AttachProp(bool detach = false)
+        {
+            if (!meidoManager.HasActiveMeido) return;
+
+            MpnAttachProp prop = Constants.MpnAttachPropList[mpnAttachDropdown.SelectedItemIndex];
+
+            meidoManager.ActiveMeido.SetMpnProp(prop, detach);
+        }
+
+        private void DetachAll()
+        {
+            if (!meidoManager.HasActiveMeido) return;
+
+            meidoManager.ActiveMeido.DetachAllMpnAttach();
+        }
+    }
+}

+ 70 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/PoseWindowPanes/SaveHandPane.cs

@@ -0,0 +1,70 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class SaveHandPane : BasePane
+    {
+        private readonly MeidoManager meidoManager;
+        private readonly ComboBox categoryComboBox;
+        private readonly TextField handNameTextField;
+        private readonly Button saveLeftHandButton;
+        private readonly Button saveRightHandButton;
+        private string categoryHeader;
+        private string nameHeader;
+
+        public SaveHandPane(MeidoManager meidoManager)
+        {
+            Constants.CustomHandChange += (s, a)
+                => categoryComboBox.SetDropdownItems(Constants.CustomHandGroupList.ToArray());
+
+            this.meidoManager = meidoManager;
+
+            categoryHeader = Translation.Get("handPane", "categoryHeader");
+
+            nameHeader = Translation.Get("handPane", "nameHeader");
+
+            saveLeftHandButton = new Button(Translation.Get("handPane", "saveLeftButton"));
+            saveLeftHandButton.ControlEvent += (s, a) => SaveHand(right: false);
+
+            saveRightHandButton = new Button(Translation.Get("handPane", "saveRightButton"));
+            saveRightHandButton.ControlEvent += (s, a) => SaveHand(right: true);
+
+            categoryComboBox = new ComboBox(Constants.CustomHandGroupList.ToArray());
+
+            handNameTextField = new TextField();
+        }
+
+        protected override void ReloadTranslation()
+        {
+            categoryHeader = Translation.Get("handPane", "categoryHeader");
+            nameHeader = Translation.Get("handPane", "nameHeader");
+            saveLeftHandButton.Label = Translation.Get("handPane", "saveLeftButton");
+            saveRightHandButton.Label = Translation.Get("handPane", "saveRightButton");
+        }
+
+        public override void Draw()
+        {
+            GUI.enabled = meidoManager.HasActiveMeido;
+
+            MpsGui.Header(categoryHeader);
+            categoryComboBox.Draw(GUILayout.Width(165f));
+
+            MpsGui.Header(nameHeader);
+            handNameTextField.Draw(GUILayout.Width(165f));
+
+            GUILayout.BeginHorizontal();
+            saveRightHandButton.Draw();
+            saveLeftHandButton.Draw();
+            GUILayout.EndHorizontal();
+
+            GUI.enabled = true;
+        }
+
+        private void SaveHand(bool right)
+        {
+            byte[] handBinary = meidoManager.ActiveMeido.IKManager.SerializeHand(right);
+            Constants.AddHand(handBinary, right, handNameTextField.Value, categoryComboBox.Value);
+            handNameTextField.Value = string.Empty;
+        }
+    }
+}

+ 63 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/PoseWindowPanes/SavePosePane.cs

@@ -0,0 +1,63 @@
+using System;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class SavePosePane : BasePane
+    {
+        private readonly MeidoManager meidoManager;
+        private readonly Button savePoseButton;
+        private readonly TextField poseNameTextField;
+        private readonly ComboBox categoryComboBox;
+        private string categoryHeader;
+        private string nameHeader;
+
+        public SavePosePane(MeidoManager meidoManager)
+        {
+            Constants.CustomPoseChange += (s, a)
+                => categoryComboBox.SetDropdownItems(Constants.CustomPoseGroupList.ToArray());
+
+            this.meidoManager = meidoManager;
+
+            categoryHeader = Translation.Get("posePane", "categoryHeader");
+            nameHeader = Translation.Get("posePane", "nameHeader");
+
+            savePoseButton = new Button(Translation.Get("posePane", "saveButton"));
+            savePoseButton.ControlEvent += OnSavePose;
+
+            categoryComboBox = new ComboBox(Constants.CustomPoseGroupList.ToArray());
+            poseNameTextField = new TextField();
+            poseNameTextField.ControlEvent += OnSavePose;
+        }
+
+        protected override void ReloadTranslation()
+        {
+            categoryHeader = Translation.Get("posePane", "categoryHeader");
+            nameHeader = Translation.Get("posePane", "nameHeader");
+            savePoseButton.Label = Translation.Get("posePane", "saveButton");
+        }
+
+        public override void Draw()
+        {
+            GUI.enabled = meidoManager.HasActiveMeido;
+
+            MpsGui.Header(categoryHeader);
+            categoryComboBox.Draw(GUILayout.Width(160f));
+
+            MpsGui.Header(nameHeader);
+            GUILayout.BeginHorizontal();
+            poseNameTextField.Draw(GUILayout.Width(160f));
+            savePoseButton.Draw(GUILayout.ExpandWidth(false));
+            GUILayout.EndHorizontal();
+
+            GUI.enabled = true;
+        }
+
+        private void OnSavePose(object sender, EventArgs args)
+        {
+            byte[] anmBinary = meidoManager.ActiveMeido.SerializePose();
+            Constants.AddPose(anmBinary, poseNameTextField.Value, categoryComboBox.Value);
+            poseNameTextField.Value = string.Empty;
+        }
+    }
+}

+ 100 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/SceneManagerPanes/SceneManagerDirectoryPane.cs

@@ -0,0 +1,100 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class SceneManagerDirectoryPane : BasePane
+    {
+        public static readonly int listWidth = 200;
+        private readonly SceneManager sceneManager;
+        private readonly SceneModalWindow sceneModalWindow;
+        private readonly Button createDirectoryButton;
+        private readonly Button deleteDirectoryButton;
+        private readonly TextField directoryTextField;
+        private readonly Button cancelButton;
+        private readonly Texture2D selectedTexture = Utility.MakeTex(2, 2, new Color(0.5f, 0.5f, 0.5f, 0.4f));
+        private Vector2 listScrollPos;
+        private bool createDirectoryMode;
+
+        public SceneManagerDirectoryPane(SceneManager sceneManager, SceneModalWindow sceneModalWindow)
+        {
+            this.sceneManager = sceneManager;
+            this.sceneModalWindow = sceneModalWindow;
+
+            createDirectoryButton = new Button(Translation.Get("sceneManager", "createDirectoryButton"));
+            createDirectoryButton.ControlEvent += (s, a) => createDirectoryMode = true;
+
+            deleteDirectoryButton = new Button(Translation.Get("sceneManager", "deleteDirectoryButton"));
+            deleteDirectoryButton.ControlEvent += (s, a) => this.sceneModalWindow.ShowDirectoryDialogue();
+
+            directoryTextField = new TextField();
+            directoryTextField.ControlEvent += (s, a) =>
+            {
+                sceneManager.AddDirectory(directoryTextField.Value);
+                createDirectoryMode = false;
+                directoryTextField.Value = string.Empty;
+            };
+
+            cancelButton = new Button("X");
+            cancelButton.ControlEvent += (s, a) => createDirectoryMode = false;
+        }
+
+        protected override void ReloadTranslation()
+        {
+            createDirectoryButton.Label = Translation.Get("sceneManager", "createDirectoryButton");
+            deleteDirectoryButton.Label = Translation.Get("sceneManager", "deleteDirectoryButton");
+        }
+
+        public override void Draw()
+        {
+            GUIStyle directoryStyle = new GUIStyle(GUI.skin.button)
+            {
+                fontSize = Utility.GetPix(12),
+                alignment = TextAnchor.MiddleLeft,
+                margin = new RectOffset(0, 0, 0, 0)
+            };
+
+            GUIStyle directorySelectedStyle = new GUIStyle(directoryStyle);
+            directorySelectedStyle.normal.textColor = Color.white;
+            directorySelectedStyle.normal.background = selectedTexture;
+            directorySelectedStyle.hover.background = selectedTexture;
+
+            GUILayout.BeginVertical(GUILayout.Width(Utility.GetPix(listWidth)));
+
+            listScrollPos = GUILayout.BeginScrollView(listScrollPos);
+
+            for (int i = 0; i < sceneManager.CurrentDirectoryList.Count; i++)
+            {
+                GUIStyle style = i == sceneManager.CurrentDirectoryIndex ? directorySelectedStyle : directoryStyle;
+                string directoryName = sceneManager.CurrentDirectoryList[i];
+                if (GUILayout.Button(directoryName, style, GUILayout.Height(Utility.GetPix(20))))
+                {
+                    sceneManager.SelectDirectory(i);
+                }
+            }
+
+            GUILayout.EndScrollView();
+
+            GUILayout.BeginHorizontal();
+
+            GUIStyle buttonStyle = new GUIStyle(GUI.skin.button) { fontSize = Utility.GetPix(12) };
+
+            GUILayoutOption buttonHeight = GUILayout.Height(Utility.GetPix(20));
+
+            if (createDirectoryMode)
+            {
+                directoryTextField.Draw(buttonHeight, GUILayout.Width(Utility.GetPix(listWidth - 30)));
+                cancelButton.Draw(buttonStyle, buttonHeight, GUILayout.ExpandWidth(false));
+            }
+            else
+            {
+                createDirectoryButton.Draw(buttonStyle, buttonHeight);
+                GUI.enabled = sceneManager.CurrentDirectoryIndex > 0;
+                deleteDirectoryButton.Draw(buttonStyle, buttonHeight, GUILayout.ExpandWidth(false));
+                GUI.enabled = true;
+            }
+            GUILayout.EndHorizontal();
+
+            GUILayout.EndVertical();
+        }
+    }
+}

+ 85 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/SceneManagerPanes/SceneManagerScenePane.cs

@@ -0,0 +1,85 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class SceneManagerScenePane : BasePane
+    {
+        public static readonly float thumbnailScale = 0.55f;
+        private readonly SceneManager sceneManager;
+        private readonly SceneModalWindow sceneModalWindow;
+        private readonly Button addSceneButton;
+        private Vector2 sceneScrollPos;
+
+        public SceneManagerScenePane(SceneManager sceneManager, SceneModalWindow sceneModalWindow)
+        {
+            this.sceneManager = sceneManager;
+            this.sceneModalWindow = sceneModalWindow;
+
+            addSceneButton = new Button("+");
+            addSceneButton.ControlEvent += (s, a) => sceneManager.SaveScene(overwrite: false);
+        }
+
+        public override void Draw()
+        {
+            GUIStyle sceneImageStyle = new GUIStyle(GUI.skin.label)
+            {
+                alignment = TextAnchor.MiddleCenter,
+                padding = new RectOffset(0, 0, 0, 0)
+            };
+
+            GUIStyle addSceneStyle = new GUIStyle(GUI.skin.button)
+            {
+                alignment = TextAnchor.MiddleCenter,
+                fontSize = 60
+            };
+
+            GUILayout.BeginVertical();
+
+            float sceneWidth = SceneManager.sceneDimensions.x * thumbnailScale;
+            float sceneHeight = SceneManager.sceneDimensions.y * thumbnailScale;
+            float sceneGridWidth = parent.WindowRect.width - SceneManagerDirectoryPane.listWidth;
+
+            GUILayoutOption[] sceneLayoutOptions = new[] { GUILayout.Height(sceneHeight), GUILayout.Width(sceneWidth) };
+
+            int columns = Mathf.Max(1, (int)(sceneGridWidth / sceneWidth));
+            int rows = (int)Mathf.Ceil(sceneManager.SceneList.Count + (1 / (float)columns));
+
+            sceneScrollPos = GUILayout.BeginScrollView(sceneScrollPos);
+
+            GUILayout.BeginHorizontal();
+            GUILayout.FlexibleSpace();
+            GUILayout.BeginVertical();
+
+            int currentScene = -1;
+            for (int i = 0; i < rows; i++)
+            {
+                GUILayout.BeginHorizontal();
+                for (int j = 0; j < columns; j++, currentScene++)
+                {
+                    if (currentScene == -1)
+                    {
+                        addSceneButton.Draw(addSceneStyle, sceneLayoutOptions);
+                    }
+                    else if (currentScene < sceneManager.SceneList.Count)
+                    {
+                        var scene = sceneManager.SceneList[currentScene];
+                        if (GUILayout.Button(scene.Thumbnail, sceneImageStyle, sceneLayoutOptions))
+                        {
+                            sceneManager.SelectScene(currentScene);
+                            sceneModalWindow.ShowSceneDialogue();
+                        }
+                    }
+                }
+                GUILayout.EndHorizontal();
+            }
+
+            GUILayout.EndVertical();
+            GUILayout.FlexibleSpace();
+            GUILayout.EndHorizontal();
+
+            GUILayout.EndScrollView();
+
+            GUILayout.EndVertical();
+        }
+    }
+}

+ 108 - 0
src/MeidoPhotoStudio.Plugin/GUI/Panes/SceneManagerPanes/SceneManagerTitleBar.cs

@@ -0,0 +1,108 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class SceneManagerTitleBarPane : BasePane
+    {
+        private static readonly string[] sortModes = new[] { "sortName", "sortCreated", "sortModified" };
+        private readonly SceneManager sceneManager;
+        private readonly Button kankyoToggle;
+        private readonly Button refreshButton;
+        private readonly Dropdown sortDropdown;
+        private readonly Toggle descendingToggle;
+        private readonly Button closeButton;
+        private string sortLabel;
+        public event System.EventHandler CloseChange;
+
+        public SceneManagerTitleBarPane(SceneManager sceneManager)
+        {
+            this.sceneManager = sceneManager;
+            kankyoToggle = new Button(Translation.Get("sceneManager", "kankyoToggle"));
+            kankyoToggle.ControlEvent += (s, a) => sceneManager.ToggleKankyoMode();
+
+            refreshButton = new Button(Translation.Get("sceneManager", "refreshButton"));
+            refreshButton.ControlEvent += (s, a) => sceneManager.Refresh();
+
+            sortDropdown = new Dropdown(
+                Translation.GetArray("sceneManager", sortModes), (int)sceneManager.CurrentSortMode
+            );
+            sortDropdown.SelectionChange += (s, a) =>
+            {
+                SceneManager.SortMode sortMode = (SceneManager.SortMode)sortDropdown.SelectedItemIndex;
+                if (sceneManager.CurrentSortMode == sortMode) return;
+                sceneManager.SortScenes(sortMode);
+            };
+
+            descendingToggle = new Toggle(
+                Translation.Get("sceneManager", "descendingToggle"), sceneManager.SortDescending
+            );
+            descendingToggle.ControlEvent += (s, a) =>
+            {
+                sceneManager.SortDescending = descendingToggle.Value;
+                sceneManager.SortScenes(sceneManager.CurrentSortMode);
+            };
+
+            closeButton = new Button("X");
+            closeButton.ControlEvent += (s, a) => CloseChange?.Invoke(this, System.EventArgs.Empty);
+
+            sortLabel = Translation.Get("sceneManager", "sortLabel");
+        }
+
+        protected override void ReloadTranslation()
+        {
+            kankyoToggle.Label = Translation.Get("sceneManager", "kankyoToggle");
+            refreshButton.Label = Translation.Get("sceneManager", "refreshButton");
+            sortDropdown.SetDropdownItems(Translation.GetArray("sceneManager", sortModes));
+            descendingToggle.Label = Translation.Get("sceneManager", "descendingToggle");
+            sortLabel = Translation.Get("sceneManager", "sortLabel");
+        }
+
+        public override void Draw()
+        {
+            GUIStyle buttonStyle = new GUIStyle(GUI.skin.button) { fontSize = Utility.GetPix(12) };
+
+            GUILayoutOption buttonHeight = GUILayout.Height(Utility.GetPix(20));
+            GUILayout.BeginHorizontal();
+
+            GUILayout.BeginHorizontal(GUILayout.Width(Utility.GetPix(SceneManagerDirectoryPane.listWidth)));
+
+            Color originalColour = GUI.backgroundColor;
+            if (sceneManager.KankyoMode) GUI.backgroundColor = Color.green;
+            kankyoToggle.Draw(buttonStyle, buttonHeight);
+            GUI.backgroundColor = originalColour;
+
+            GUILayout.FlexibleSpace();
+
+            refreshButton.Draw(buttonStyle, buttonHeight);
+
+            GUILayout.EndHorizontal();
+
+            GUILayout.BeginHorizontal();
+
+            GUILayout.Space(Utility.GetPix(15));
+
+            GUIStyle labelStyle = new GUIStyle(GUI.skin.label) { fontSize = buttonStyle.fontSize };
+
+            GUILayout.Label(sortLabel, labelStyle);
+
+            GUIStyle dropdownStyle = new GUIStyle(DropdownHelper.DefaultDropdownStyle)
+            {
+                fontSize = buttonStyle.fontSize
+            };
+
+            sortDropdown.Draw(buttonStyle, dropdownStyle, buttonHeight, GUILayout.Width(Utility.GetPix(100)));
+
+            GUIStyle toggleStyle = new GUIStyle(GUI.skin.toggle) { fontSize = buttonStyle.fontSize };
+
+            descendingToggle.Draw(toggleStyle);
+
+            GUILayout.FlexibleSpace();
+
+            closeButton.Draw();
+
+            GUILayout.EndHorizontal();
+
+            GUILayout.EndHorizontal();
+        }
+    }
+}

+ 50 - 0
src/MeidoPhotoStudio.Plugin/GUI/Windows/BaseWindow.cs

@@ -0,0 +1,50 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public abstract class BaseWindow : BaseWindowPane
+    {
+        private static int id = 765;
+        private static int ID => id++;
+        public readonly int windowID = ID;
+        protected Rect windowRect = new Rect(0f, 0f, 480f, 270f);
+        public virtual Rect WindowRect
+        {
+            get => windowRect;
+            set
+            {
+                value.x = Mathf.Clamp(
+                    value.x, -value.width + Utility.GetPix(20), Screen.width - Utility.GetPix(20)
+                );
+                value.y = Mathf.Clamp(
+                    value.y, -value.height + Utility.GetPix(20), Screen.height - Utility.GetPix(20)
+                );
+                windowRect = value;
+            }
+        }
+        protected Vector2 MiddlePosition => new Vector2(
+            (Screen.width / 2) - (windowRect.width / 2), (Screen.height / 2) - (windowRect.height / 2)
+        );
+
+        public virtual void HandleZoom()
+        {
+            if (Input.mouseScrollDelta.y != 0f && Visible)
+            {
+                Vector2 mousePos = new Vector2(Input.mousePosition.x, Screen.height - Input.mousePosition.y);
+                if (WindowRect.Contains(mousePos)) Input.ResetInputAxes();
+            }
+        }
+
+        public virtual void Update() => HandleZoom();
+
+        public virtual void Activate() { }
+
+        public virtual void Deactivate() { }
+
+        public virtual void GUIFunc(int id)
+        {
+            Draw();
+            GUI.DragWindow();
+        }
+    }
+}

+ 23 - 0
src/MeidoPhotoStudio.Plugin/GUI/Windows/BaseWindowPane.cs

@@ -0,0 +1,23 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public abstract class BaseWindowPane : BasePane
+    {
+        protected List<BasePane> Panes = new List<BasePane>();
+        protected Vector2 scrollPos;
+        public bool ActiveWindow { get; set; }
+
+        public T AddPane<T>(T pane) where T : BasePane
+        {
+            Panes.Add(pane);
+            return pane;
+        }
+
+        public virtual void UpdatePanes()
+        {
+            foreach (BasePane pane in Panes) pane.UpdatePane();
+        }
+    }
+}

+ 148 - 0
src/MeidoPhotoStudio.Plugin/GUI/Windows/MainWindow.cs

@@ -0,0 +1,148 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class MainWindow : BaseWindow
+    {
+        private readonly MeidoManager meidoManager;
+        private readonly Dictionary<Constants.Window, BaseMainWindowPane> windowPanes;
+        private readonly PropManager propManager;
+        private readonly LightManager lightManager;
+        private readonly TabsPane tabsPane;
+        private readonly Button settingsButton;
+        private BaseMainWindowPane currentWindowPane;
+        public override Rect WindowRect
+        {
+            set
+            {
+                value.width = 240f;
+                value.height = Screen.height * 0.9f;
+                if (MeidoPhotoStudio.EditMode) value.height *= 0.85f;
+                value.x = Mathf.Clamp(value.x, 0, Screen.width - value.width);
+                value.y = Mathf.Clamp(value.y, -value.height + 30, Screen.height - 50);
+                windowRect = value;
+            }
+        }
+        private Constants.Window selectedWindow;
+
+        public BaseMainWindowPane this[Constants.Window id]
+        {
+            get => windowPanes[id];
+            set => AddWindow(id, value);
+        }
+
+        // TODO: Find a better way of doing this
+        public MainWindow(MeidoManager meidoManager, PropManager propManager, LightManager lightManager)
+        {
+            this.meidoManager = meidoManager;
+            this.meidoManager.UpdateMeido += UpdateMeido;
+
+            this.propManager = propManager;
+            this.propManager.FromPropSelect += (s, a) => ChangeWindow(Constants.Window.BG2);
+
+            this.lightManager = lightManager;
+            this.lightManager.Select += (s, a) => ChangeWindow(Constants.Window.BG);
+
+            windowPanes = new Dictionary<Constants.Window, BaseMainWindowPane>();
+            WindowRect = new Rect(Screen.width, Screen.height * 0.08f, 240f, Screen.height * 0.9f);
+
+            tabsPane = new TabsPane();
+            tabsPane.TabChange += (s, a) => ChangeTab();
+
+            settingsButton = new Button("Settings");
+            settingsButton.ControlEvent += (s, a) =>
+            {
+                if (selectedWindow == Constants.Window.Settings) ChangeTab();
+                else
+                {
+                    settingsButton.Label = "Close";
+                    SetCurrentWindow(Constants.Window.Settings);
+                }
+            };
+        }
+
+        public override void Activate()
+        {
+            updating = true;
+            tabsPane.SelectedTab = Constants.Window.Call;
+            updating = false;
+            Visible = true;
+        }
+
+        public void AddWindow(Constants.Window id, BaseMainWindowPane window)
+        {
+            if (windowPanes.ContainsKey(id))
+            {
+                Panes.Remove(windowPanes[id]);
+            }
+            windowPanes[id] = window;
+            windowPanes[id].SetTabsPane(tabsPane);
+            windowPanes[id].SetParent(this);
+            Panes.Add(windowPanes[id]);
+        }
+
+        private void ChangeTab()
+        {
+            settingsButton.Label = "Settings";
+            SetCurrentWindow(tabsPane.SelectedTab);
+        }
+
+        private void SetCurrentWindow(Constants.Window window)
+        {
+            if (currentWindowPane != null) currentWindowPane.ActiveWindow = false;
+            selectedWindow = window;
+            currentWindowPane = windowPanes[selectedWindow];
+            currentWindowPane.ActiveWindow = true;
+            currentWindowPane.UpdatePanes();
+        }
+
+        public override void Update()
+        {
+            base.Update();
+            if (InputManager.GetKeyDown(MpsKey.ToggleUI)) Visible = !Visible;
+        }
+
+        public override void Draw()
+        {
+            currentWindowPane?.Draw();
+
+            GUI.enabled = true;
+
+            GUILayout.FlexibleSpace();
+
+            GUIStyle labelStyle = new GUIStyle(GUI.skin.label)
+            {
+                fontSize = 10,
+                alignment = TextAnchor.LowerLeft
+            };
+
+            GUILayout.BeginHorizontal();
+            GUILayout.Label(MeidoPhotoStudio.pluginString, labelStyle);
+            GUILayout.FlexibleSpace();
+            GUI.enabled = !InputManager.Listening;
+            settingsButton.Draw(GUILayout.ExpandWidth(false));
+            GUI.enabled = true;
+            GUILayout.EndHorizontal();
+
+            GUI.DragWindow();
+        }
+
+        private void UpdateMeido(object sender, MeidoUpdateEventArgs args)
+        {
+            if (args.FromMeido)
+            {
+                Constants.Window newWindow = args.IsBody ? Constants.Window.Pose : Constants.Window.Face;
+                ChangeWindow(newWindow);
+            }
+            else currentWindowPane.UpdatePanes();
+        }
+
+        private void ChangeWindow(Constants.Window window)
+        {
+            if (selectedWindow == window) currentWindowPane.UpdatePanes();
+            else tabsPane.SelectedTab = window;
+            Visible = true;
+        }
+    }
+}

+ 88 - 0
src/MeidoPhotoStudio.Plugin/GUI/Windows/MessageWindow.cs

@@ -0,0 +1,88 @@
+using System;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class MessageWindow : BaseWindow
+    {
+        private readonly MessageWindowManager messageWindowManager;
+        private readonly TextField nameTextField;
+        private readonly Slider fontSizeSlider;
+        private readonly TextArea messageTextArea;
+        private readonly Button okButton;
+        public override Rect WindowRect
+        {
+            set
+            {
+                value.width = Mathf.Clamp(Screen.width * 0.4f, 440, Mathf.Infinity);
+                value.height = Mathf.Clamp(Screen.height * 0.15f, 150, Mathf.Infinity);
+                base.WindowRect = value;
+            }
+        }
+        private int fontSize = 25;
+
+        public MessageWindow(MessageWindowManager messageWindowManager)
+        {
+            WindowRect = WindowRect;
+            windowRect.x = MiddlePosition.x;
+            windowRect.y = Screen.height - WindowRect.height;
+            this.messageWindowManager = messageWindowManager;
+            nameTextField = new TextField();
+
+            fontSizeSlider = new Slider(MessageWindowManager.fontBounds);
+            fontSizeSlider.ControlEvent += ChangeFontSize;
+
+            messageTextArea = new TextArea();
+
+            okButton = new Button("OK");
+            okButton.ControlEvent += ShowMessage;
+        }
+
+        private void ToggleVisibility()
+        {
+            if (messageWindowManager.ShowingMessage) messageWindowManager.CloseMessagePanel();
+            else Visible = !Visible;
+        }
+
+        private void ChangeFontSize(object sender, EventArgs args)
+        {
+            fontSize = (int)fontSizeSlider.Value;
+            messageWindowManager.FontSize = fontSize;
+        }
+
+        private void ShowMessage(object sender, EventArgs args)
+        {
+            Visible = false;
+            messageWindowManager.ShowMessage(nameTextField.Value, messageTextArea.Value);
+        }
+
+        public override void Update()
+        {
+            base.Update();
+            if (InputManager.GetKeyDown(MpsKey.ToggleMessage)) ToggleVisibility();
+        }
+
+        public override void Draw()
+        {
+            GUILayout.BeginHorizontal();
+            GUILayout.Label("Name", GUILayout.ExpandWidth(false));
+            nameTextField.Draw(GUILayout.Width(120));
+
+            GUILayout.Space(30);
+
+            GUILayout.Label("Font Size", GUILayout.ExpandWidth(false));
+            fontSizeSlider.Draw(GUILayout.Width(120), GUILayout.ExpandWidth(false));
+            GUILayout.Label($"{fontSize}pt");
+            GUILayout.EndHorizontal();
+
+            messageTextArea.Draw(GUILayout.MinHeight(90));
+            okButton.Draw(GUILayout.ExpandWidth(false), GUILayout.Width(30));
+        }
+
+        public override void Deactivate()
+        {
+            messageWindowManager.CloseMessagePanel();
+            Visible = false;
+        }
+    }
+}

+ 250 - 0
src/MeidoPhotoStudio.Plugin/GUI/Windows/SceneModalWindow.cs

@@ -0,0 +1,250 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class SceneModalWindow : BaseWindow
+    {
+        private static readonly Texture2D infoHighlight = Utility.MakeTex(2, 2, new Color(0f, 0f, 0f, 0.8f));
+        private readonly SceneManager sceneManager;
+        public override Rect WindowRect
+        {
+            set
+            {
+                value.width = Mathf.Clamp(Screen.width * 0.3f, 360f, 500f);
+                value.height = directoryMode ? 150f : Mathf.Clamp(Screen.height * 0.4f, 240f, 380f);
+                base.WindowRect = value;
+            }
+        }
+        private bool visible;
+        public override bool Visible
+        {
+            get => visible;
+            set
+            {
+                visible = value;
+                if (value)
+                {
+                    WindowRect = WindowRect;
+                    windowRect.x = MiddlePosition.x;
+                    windowRect.y = MiddlePosition.y;
+                }
+            }
+        }
+
+        private readonly Button okButton;
+        private readonly Button cancelButton;
+        private readonly Button deleteButton;
+        private readonly Button overwriteButton;
+        private string deleteDirectoryMessage;
+        private string deleteSceneMessage;
+        private string directoryDeleteCommit;
+        private string sceneDeleteCommit;
+        private string sceneLoadCommit;
+        private string infoKankyo;
+        private string infoMaidSingular;
+        private string infoMaidPlural;
+        private bool directoryMode;
+        private bool deleteScene;
+
+        public SceneModalWindow(SceneManager sceneManager)
+        {
+            ReloadTranslation();
+
+            this.sceneManager = sceneManager;
+
+            windowRect.x = MiddlePosition.x;
+            windowRect.y = MiddlePosition.y;
+            okButton = new Button(sceneLoadCommit);
+            okButton.ControlEvent += (s, a) => Commit();
+
+            cancelButton = new Button("Cancel");
+            cancelButton.ControlEvent += (s, a) => Cancel();
+
+            deleteButton = new Button("Delete");
+            deleteButton.ControlEvent += (s, a) =>
+            {
+                okButton.Label = sceneDeleteCommit;
+                deleteScene = true;
+            };
+
+            overwriteButton = new Button("Overwrite");
+            overwriteButton.ControlEvent += (s, a) =>
+            {
+                sceneManager.OverwriteScene();
+                Visible = false;
+            };
+        }
+
+        protected override void ReloadTranslation()
+        {
+            deleteDirectoryMessage = Translation.Get("sceneManagerModal", "deleteDirectoryConfirm");
+            deleteSceneMessage = Translation.Get("sceneManagerModal", "deleteFileConfirm");
+            directoryDeleteCommit = Translation.Get("sceneManagerModal", "deleteDirectoryButton");
+            sceneDeleteCommit = Translation.Get("sceneManagerModal", "deleteFileCommit");
+            sceneLoadCommit = Translation.Get("sceneManagerModal", "fileLoadCommit");
+            infoKankyo = Translation.Get("sceneManagerModal", "infoKankyo");
+            infoMaidSingular = Translation.Get("sceneManagerModal", "infoMaidSingular");
+            infoMaidPlural = Translation.Get("sceneManagerModal", "infoMaidPlural");
+        }
+
+        public override void Draw()
+        {
+            GUILayout.BeginArea(new Rect(10f, 10f, WindowRect.width - 20f, WindowRect.height - 20f));
+
+            // thumbnail
+            if (!directoryMode)
+            {
+                MPSScene scene = sceneManager.CurrentScene;
+                Texture2D thumb = scene.Thumbnail;
+
+                float scale = Mathf.Min(
+                    (WindowRect.width - 20f) / thumb.width, (WindowRect.height - 110f) / thumb.height
+                );
+                float width = Mathf.Min(thumb.width, thumb.width * scale);
+                float height = Mathf.Min(thumb.height, thumb.height * scale);
+
+                GUILayout.BeginHorizontal();
+                GUILayout.FlexibleSpace();
+
+                MpsGui.DrawTexture(thumb, GUILayout.Width(width), GUILayout.Height(height));
+
+                GUIStyle labelStyle = new GUIStyle(GUI.skin.label)
+                {
+                    fontSize = Utility.GetPix(12),
+                    alignment = TextAnchor.MiddleCenter
+                };
+                labelStyle.normal.background = infoHighlight;
+
+                Rect labelBox = GUILayoutUtility.GetLastRect();
+
+                var infoString = scene.Environment
+                    ? infoKankyo
+                    : string.Format(scene.NumberOfMaids == 1 ? infoMaidSingular : infoMaidPlural, scene.NumberOfMaids);
+
+                Vector2 labelSize = labelStyle.CalcSize(new GUIContent(infoString));
+
+                labelBox = new Rect(
+                    labelBox.x + 10, labelBox.y + labelBox.height - (labelSize.y + 10),
+                    labelSize.x + 10, labelSize.y + 2
+                );
+
+                GUI.Label(labelBox, infoString, labelStyle);
+
+                GUILayout.FlexibleSpace();
+                GUILayout.EndHorizontal();
+            }
+
+            // message
+            string currentMessage;
+            string context;
+
+            if (directoryMode)
+            {
+                currentMessage = deleteDirectoryMessage;
+                context = sceneManager.CurrentDirectoryName;
+            }
+            else
+            {
+                if (deleteScene)
+                {
+                    currentMessage = deleteSceneMessage;
+                    context = sceneManager.CurrentScene.FileInfo.Name;
+                }
+                else
+                {
+                    currentMessage = sceneManager.CurrentScene.FileInfo.Name;
+                    context = currentMessage;
+                }
+            }
+
+            GUIStyle messageStyle = new GUIStyle(GUI.skin.label)
+            {
+                alignment = TextAnchor.MiddleCenter,
+                fontSize = Utility.GetPix(12)
+            };
+
+            GUILayout.FlexibleSpace();
+
+            GUILayout.Label(string.Format(currentMessage, context), messageStyle);
+
+            GUILayout.FlexibleSpace();
+
+            // Buttons
+
+            GUIStyle buttonStyle = new GUIStyle(GUI.skin.button)
+            {
+                fontSize = Utility.GetPix(12)
+            };
+
+            GUILayoutOption buttonHeight = GUILayout.Height(Utility.GetPix(20));
+
+            GUILayout.BeginHorizontal();
+
+            if (directoryMode || deleteScene)
+            {
+                GUILayout.FlexibleSpace();
+                okButton.Draw(buttonStyle, buttonHeight, GUILayout.ExpandWidth(false));
+                cancelButton.Draw(buttonStyle, buttonHeight, GUILayout.Width(100));
+            }
+            else
+            {
+                deleteButton.Draw(buttonStyle, buttonHeight, GUILayout.ExpandWidth(false));
+                overwriteButton.Draw(buttonStyle, buttonHeight, GUILayout.ExpandWidth(false));
+
+                GUILayout.FlexibleSpace();
+
+                okButton.Draw(buttonStyle, buttonHeight, GUILayout.ExpandWidth(false));
+                cancelButton.Draw(buttonStyle, buttonHeight, GUILayout.Width(100));
+            }
+
+            GUILayout.EndHorizontal();
+
+            GUILayout.EndArea();
+        }
+
+        public void ShowDirectoryDialogue()
+        {
+            okButton.Label = directoryDeleteCommit;
+            directoryMode = true;
+            Modal.Show(this);
+        }
+
+        public void ShowSceneDialogue()
+        {
+            directoryMode = false;
+            okButton.Label = sceneLoadCommit;
+            Modal.Show(this);
+        }
+
+        private void Commit()
+        {
+            if (directoryMode)
+            {
+                sceneManager.DeleteDirectory();
+                Modal.Close();
+            }
+            else
+            {
+                if (deleteScene)
+                {
+                    sceneManager.DeleteScene();
+                    deleteScene = false;
+                }
+                else sceneManager.LoadScene(sceneManager.CurrentScene);
+
+                Modal.Close();
+            }
+        }
+
+        private void Cancel()
+        {
+            if (directoryMode) Modal.Close();
+            else
+            {
+                if (deleteScene) deleteScene = false;
+                else Modal.Close();
+            }
+            okButton.Label = sceneLoadCommit;
+        }
+    }
+}

+ 110 - 0
src/MeidoPhotoStudio.Plugin/GUI/Windows/SceneWindow.cs

@@ -0,0 +1,110 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class SceneWindow : BaseWindow
+    {
+        private const float resizeHandleSize = 15f;
+        private readonly SceneManager sceneManager;
+        private readonly SceneManagerTitleBarPane titleBar;
+        private readonly SceneManagerDirectoryPane directoryList;
+        private readonly SceneManagerScenePane sceneGrid;
+        private Rect resizeHandleRect;
+        private bool resizing;
+        private bool visible;
+        public override bool Visible
+        {
+            get => visible;
+            set
+            {
+                visible = value;
+                if (visible && !sceneManager.Initialized) sceneManager.Initialize();
+            }
+        }
+
+        public SceneWindow(SceneManager sceneManager)
+        {
+            windowRect.width = Screen.width * 0.65f;
+            windowRect.height = Screen.height * 0.75f;
+            windowRect.x = (Screen.width * 0.5f) - (windowRect.width * 0.5f);
+            windowRect.y = (Screen.height * 0.5f) - (windowRect.height * 0.5f);
+
+            resizeHandleRect = new Rect(0f, 0f, resizeHandleSize, resizeHandleSize);
+
+            this.sceneManager = sceneManager;
+            SceneModalWindow sceneModalWindow = new SceneModalWindow(this.sceneManager);
+
+            titleBar = AddPane(new SceneManagerTitleBarPane(sceneManager));
+            titleBar.CloseChange += (s, a) => Visible = false;
+
+            directoryList = AddPane(new SceneManagerDirectoryPane(sceneManager, sceneModalWindow));
+
+            sceneGrid = AddPane(new SceneManagerScenePane(sceneManager, sceneModalWindow));
+
+            sceneGrid.SetParent(this);
+        }
+
+        public override void GUIFunc(int id)
+        {
+            HandleResize();
+            Draw();
+            if (!resizing) GUI.DragWindow();
+        }
+
+        public override void Update()
+        {
+            base.Update();
+            if (InputManager.GetKeyDown(MpsKey.OpenSceneManager)) Visible = !Visible;
+        }
+
+        public override void Deactivate()
+        {
+            Visible = false;
+        }
+
+        public override void Draw()
+        {
+            GUI.enabled = !SceneManager.Busy && !Modal.Visible;
+
+            GUILayout.BeginArea(new Rect(10f, 10f, windowRect.width - 20f, windowRect.height - 20f));
+
+            titleBar.Draw();
+
+            GUILayout.BeginHorizontal();
+            directoryList.Draw();
+            sceneGrid.Draw();
+            GUILayout.EndHorizontal();
+
+            GUILayout.EndArea();
+
+            GUI.Box(resizeHandleRect, GUIContent.none);
+        }
+
+        private void HandleResize()
+        {
+            resizeHandleRect.x = windowRect.width - resizeHandleSize;
+            resizeHandleRect.y = windowRect.height - resizeHandleSize;
+
+            if (!resizing && Input.GetMouseButton(0) && resizeHandleRect.Contains(Event.current.mousePosition))
+            {
+                resizing = true;
+            }
+
+            if (resizing)
+            {
+                float rectWidth = Event.current.mousePosition.x;
+                float rectHeight = Event.current.mousePosition.y;
+
+                float minWidth = Utility.GetPix(
+                    SceneManagerDirectoryPane.listWidth
+                    + (int)(SceneManager.sceneDimensions.x * SceneManagerScenePane.thumbnailScale)
+                );
+
+                windowRect.width = Mathf.Max(minWidth, rectWidth);
+                windowRect.height = Mathf.Max(300, rectHeight);
+
+                if (!Input.GetMouseButton(0)) resizing = false;
+            }
+        }
+    }
+}

+ 222 - 0
src/MeidoPhotoStudio.Plugin/LexicographicStringComparer.cs

@@ -0,0 +1,222 @@
+/* 
+    Taken from https://gist.github.com/mstum/63a6e3e8cf54e8ae55b6aa28ca6f20c5
+
+    Modified slightly to remove the need for unsafe and changed namespace to plugin namespace
+*/
+using System;
+using System.Collections.Generic;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    /// <summary>
+    /// A string comparer that behaves like StrCmpLogicalW
+    /// https://msdn.microsoft.com/en-us/library/windows/desktop/bb759947
+    /// 
+    /// This means:
+    /// * case insensitive (ZA == za)
+    /// * numbers are treated as numbers (z20 &gt; z3) and assumed positive
+    ///     (-100 comes AFTER 10 and 100, because the minus is seen
+    ///         as a char, not as part of the number)
+    /// * leading zeroes come before anything else (z001 &lt; z01 &lt; z1)
+    /// 
+    /// Note: Instead of instantiating this, you can also use
+    /// <see cref="Comparison(string, string)"/>
+    /// if you don't need an <see cref="IComparer{string}"/> but can
+    /// use a <see cref="Comparison{string}"/> delegate instead.
+    /// </summary>
+    /// <remarks>
+    /// NOTE: This behaves slightly different than StrCmpLogicalW because
+    /// it handles large numbers.
+    /// 
+    /// At some point, StrCmpLogicalW just gives up trying to parse
+    /// something as a number (see the Test cases), while we keep going.
+    /// Since we want to sort lexicographily as much as possible,
+    /// that difference makes sense.
+    /// </remarks>
+    public class LexicographicStringComparer : IComparer<string>
+    {
+        /// <summary>
+        /// A <see cref="Comparison{string}"/> delegate.
+        /// </summary>
+        public static int Comparison(string x, string y)
+        {
+            // 1 = x > y, -1 = y > x, 0 = x == y
+            // Rules: Numbers < Letters. Space < everything
+            if (x == y) return 0;
+            if (string.IsNullOrEmpty(x) && !string.IsNullOrEmpty(y)) return -1;
+            if (!string.IsNullOrEmpty(x) && string.IsNullOrEmpty(y)) return 1;
+            if (string.IsNullOrEmpty(x) && string.IsNullOrEmpty(y)) return 0; // "" and null are the same for the purposes of this
+
+            var yl = y.Length;
+            for (int i = 0; i < x.Length; i++)
+            {
+                if (yl <= i) return 1;
+                var cx = x[i];
+                var cy = y[i];
+
+                if (Char.IsWhiteSpace(cx) && !Char.IsWhiteSpace(cy)) return -1;
+                if (!Char.IsWhiteSpace(cx) && Char.IsWhiteSpace(cy)) return 1;
+
+                if (IsDigit(cx))
+                {
+                    if (!IsDigit(cy))
+                    {
+                        return -1;
+                    }
+
+                    // Both are digits, but now we need to look at them as a whole, since
+                    // 10 > 2, but 10 > 002 > 02 > 2
+                    var numCmp = CompareNumbers(x, y, i, out int numChars);
+                    if (numCmp != 0) return numCmp;
+                    i += numChars; // We might have looked at more than one char, e.g., "10" is 2 chars
+                }
+                else if (IsDigit(cy))
+                {
+                    return 1;
+                }
+                else
+                {
+                    // Do this after the digit check
+                    // Case insensitive
+                    // Normalize to Uppercase:
+                    // https://docs.microsoft.com/en-US/visualstudio/code-quality/ca1308-normalize-strings-to-uppercase
+                    var cmp = Char.ToUpper(cx).CompareTo(Char.ToUpper(cy));
+                    if (cmp != 0) return cmp;
+                }
+            }
+
+            // Strings are equal to that point, and y is at least as large as x
+            if (y.Length > x.Length) return -1;
+
+            return 0;
+        }
+
+        /// <summary>
+        /// <see cref="IComparer{T}.Compare(T, T)"/>
+        /// </summary>
+        public int Compare(string x, string y)
+            => Comparison(x, y);
+
+        private static int CompareNumbers(string x, string y, int ix, out int numChars)
+        {
+            var xParsed = ParseNumber(x, ix);
+            var yParsed = ParseNumber(y, ix);
+
+            numChars = yParsed.NumCharsRead > xParsed.NumCharsRead
+                ? xParsed.NumCharsRead
+                : yParsed.NumCharsRead;
+
+            return xParsed.CompareTo(yParsed);
+        }
+
+        private static ParsedNumber ParseNumber(string str, int offset)
+        {
+            var result = 0;
+            var numChars = 0;
+            var leadingZeroes = 0;
+            var numOverflows = 0;
+            bool countZeroes = true;
+
+            for (int j = offset; j < str.Length; j++)
+            {
+                char c = str[j];
+                if (IsDigit(c))
+                {
+                    var cInt = (c - 48); // 48/0x30 is '0'
+
+                    checked
+                    {
+                        long tmp = (result * 10L) + cInt;
+                        if (tmp > int.MaxValue)
+                        {
+                            numOverflows++;
+                            tmp = tmp % int.MaxValue;
+                        }
+                        result = (int)tmp;
+                        numChars++;
+                    }
+
+                    if (cInt == 0 && countZeroes)
+                    {
+                        leadingZeroes++;
+                    }
+                    else
+                    {
+                        countZeroes = false;
+                    }
+                }
+                else
+                {
+                    break;
+                }
+            }
+            return new ParsedNumber(result, numOverflows, leadingZeroes, numChars);
+        }
+
+        private static bool IsDigit(char c) => (c >= '0' && c <= '9');
+
+        /// <summary>
+        /// Note that the ParsedNumber is not very useful as a number,
+        /// but purely as a way to compare two numbers that are stored in a string.
+        /// </summary>
+        private struct ParsedNumber : IComparable<ParsedNumber>, IComparer<ParsedNumber>
+        {
+            /// <summary>
+            /// The remainder, that is, the part of the number that
+            /// didn't overflow int.MaxValue.
+            /// </summary>
+            public int Remainder;
+
+            /// <summary>
+            /// How often did the number overflow int.MaxValue during parsing?
+            /// </summary>
+            public int Overflows;
+
+            /// <summary>
+            /// How many leading zeroes were there in the string during parsing?
+            /// "001" has a LeadingZeroesCount of 2.
+            /// "100" has a LeadingZeroesCount of 0.
+            /// "010" has a LeadingZeroesCount of 1.
+            /// 
+            /// This is important, because 001 comes before 01 comes before 1.
+            /// </summary>
+            public int LeadingZeroesCount;
+
+            /// <summary>
+            /// How many characters were read from the input during parsing?
+            /// </summary>
+            public int NumCharsRead;
+
+            public ParsedNumber(int remainder, int overflows, int leadingZeroes, int numChars)
+            {
+                Remainder = remainder;
+                Overflows = overflows;
+                LeadingZeroesCount = leadingZeroes;
+                NumCharsRead = numChars;
+            }
+
+            public int Compare(ParsedNumber x, ParsedNumber y)
+            {
+                // Note: if numCharsX and Y aren't equal, this doesn't matter
+                // as the return value will be either -1 or 1 anyway
+
+                if (x.Overflows > y.Overflows) return 1;
+                if (x.Overflows < y.Overflows) return -1;
+
+                // 001 > 01 > 1
+                if (x.Remainder == y.Remainder)
+                {
+                    if (x.LeadingZeroesCount > y.LeadingZeroesCount) return -1;
+                    if (x.LeadingZeroesCount < y.LeadingZeroesCount) return 1;
+                }
+
+                if (x.Remainder > y.Remainder) return 1;
+                if (x.Remainder < y.Remainder) return -1;
+                return 0;
+            }
+
+            public int CompareTo(ParsedNumber other)
+                => Compare(this, other);
+        }
+    }
+}

+ 70 - 0
src/MeidoPhotoStudio.Plugin/MPSScene.cs

@@ -0,0 +1,70 @@
+using System.IO;
+using System.Text;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class MPSScene
+    {
+        public Texture2D Thumbnail { get; }
+        public FileInfo FileInfo { get; }
+        public bool Environment { get; private set; }
+        public int NumberOfMaids { get; private set; }
+
+        private byte[] data;
+
+        public byte[] Data
+        {
+            get
+            {
+                if (data == null) Preload();
+                return data;
+            }
+            private set => data = value;
+        }
+
+        public MPSScene(string path, Texture2D thumbnail = null)
+        {
+            FileInfo = new FileInfo(path);
+
+            if (!thumbnail)
+            {
+                thumbnail = new Texture2D(1, 1, TextureFormat.ARGB32, false);
+                thumbnail.LoadImage(File.ReadAllBytes(FileInfo.FullName));
+            }
+
+            Thumbnail = thumbnail;
+        }
+
+        public void Preload()
+        {
+            if (data != null) return;
+
+            using var fileStream = FileInfo.OpenRead();
+            Utility.SeekPngEnd(fileStream);
+
+            using var memoryStream = new MemoryStream();
+
+            fileStream.CopyTo(memoryStream);
+            memoryStream.Position = 0L;
+
+            using var binaryReader = new BinaryReader(memoryStream, Encoding.UTF8);
+
+            var sceneHeader = MeidoPhotoStudio.SceneHeader;
+            if (!Utility.BytesEqual(binaryReader.ReadBytes(sceneHeader.Length), sceneHeader))
+            {
+                Utility.LogWarning($"'{FileInfo.FullName}' is not a MPS Scene");
+                return;
+            }
+
+            (_, Environment, NumberOfMaids, _) = SceneMetadata.ReadMetadata(binaryReader);
+
+            Data = memoryStream.ToArray();
+        }
+
+        public void Destroy()
+        {
+            if (Thumbnail) Object.DestroyImmediate(Thumbnail);
+        }
+    }
+}

+ 145 - 0
src/MeidoPhotoStudio.Plugin/MaidPlacementUtility.cs

@@ -0,0 +1,145 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public static class MaidPlacementUtility
+    {
+        private const float pi = Mathf.PI;
+        private const float tau = Mathf.PI * 2f;
+        public static readonly string[] placementTypes = {
+            "horizontalRow", "verticalRow", "diagonalRow", "diagonalRowInverse", "wave", "waveInverse",
+            "v", "vInverse", "circleInner", "circleOuter", "fanInner", "fanOuter"
+        };
+
+        public static int AlternatingSequence(int x) => (int)((x % 2 == 0 ? 1 : -1) * Mathf.Ceil(x / 2f));
+
+        public static void ApplyPlacement(string placementType, IList<Meido> list)
+        {
+            switch (placementType)
+            {
+                case "horizontalRow": PlacementRow(list, false); break;
+                case "verticalRow": PlacementRow(list, true); break;
+                case "diagonalRow": PlacementDiagonal(list, false); break;
+                case "diagonalRowInverse": PlacementDiagonal(list, true); break;
+                case "wave": PlacementWave(list, false); break;
+                case "waveInverse": PlacementWave(list, true); break;
+                case "v": PlacementV(list, false); break;
+                case "vInverse": PlacementV(list, true); break;
+                case "circleOuter": PlacementCircle(list, false); break;
+                case "circleInner": PlacementCircle(list, true); break;
+                case "fanInner": PlacementFan(list, false); break;
+                case "fanOuter": PlacementFan(list, true); break;
+                default: return;
+            }
+        }
+
+        public static void PlacementRow(IList<Meido> list, bool vertical = false)
+        {
+            for (int i = 0; i < list.Count; i++)
+            {
+                Vector3 position = Vector3.zero;
+
+                Maid maid = list[i].Maid;
+
+                float a = AlternatingSequence(i) * 0.6f;
+
+                if (vertical) position.z = a;
+                else position.x = a;
+
+                maid.SetPos(position);
+                maid.SetRot(Vector3.zero);
+            }
+        }
+
+        public static void PlacementDiagonal(IList<Meido> list, bool inverse = false)
+        {
+            for (int i = 0; i < list.Count; i++)
+            {
+                Maid maid = list[i].Maid;
+
+                float z = AlternatingSequence(i) * 0.5f;
+
+                maid.SetPos(inverse ? new Vector3(z, 0, -z) : new Vector3(z, 0, z));
+                maid.SetRot(Vector3.zero);
+            }
+        }
+
+        public static void PlacementWave(IList<Meido> list, bool inverse = false)
+        {
+            for (int i = 0; i < list.Count; i++)
+            {
+                Maid maid = list[i].Maid;
+
+                float x = AlternatingSequence(i) * 0.4f;
+                float z = (inverse ? -1 : 1) * Mathf.Cos(AlternatingSequence(i) * pi) * 0.35f;
+
+                maid.SetPos(new Vector3(x, 0, z));
+                maid.SetRot(Vector3.zero);
+            }
+        }
+
+        public static void PlacementV(IList<Meido> list, bool inverse = false)
+        {
+            for (int i = 0; i < list.Count; i++)
+            {
+                Maid maid = list[i].Maid;
+
+                float x = AlternatingSequence(i) * 0.4f;
+                float z = (inverse ? 1 : -1) * Mathf.Abs(AlternatingSequence(i)) * 0.4f;
+
+                maid.SetPos(new Vector3(x, 0, z));
+                maid.SetRot(Vector3.zero);
+            }
+        }
+
+        public static void PlacementCircle(IList<Meido> list, bool inner = false)
+        {
+            int maidCount = list.Count;
+
+            float radius = 0.3f + (0.1f * maidCount);
+
+            for (int i = 0; i < maidCount; i++)
+            {
+                Maid maid = list[i].Maid;
+
+                float angle = (pi / 2f) + (tau * AlternatingSequence(i) / maidCount);
+
+                float x = Mathf.Cos(angle) * radius;
+                float z = Mathf.Sin(angle) * radius;
+
+                float rotation = Mathf.Atan2(x, z);
+                if (inner) rotation += pi;
+
+                maid.SetPos(new Vector3(x, 0, z));
+                maid.SetRot(new Vector3(0, rotation * Mathf.Rad2Deg, 0));
+            }
+        }
+
+        public static void PlacementFan(IList<Meido> list, bool outer = false)
+        {
+            int maidCount = list.Count;
+
+            float radius = 0.2f + (0.2f * maidCount);
+
+            list[0].Maid.SetPos(Vector3.zero);
+            list[0].Maid.SetRot(Vector3.zero);
+
+            for (int i = 1; i < maidCount; i++)
+            {
+                Maid maid = list[i].Maid;
+
+                float angle = pi * AlternatingSequence(i - 1) / maidCount;
+
+                float x = Mathf.Sin(angle) * radius;
+                float z = Mathf.Cos(angle) * radius;
+
+                float rotation = Mathf.Atan2(x, z);
+                if (outer) rotation += pi;
+
+                maid.SetPos(new Vector3(-x, 0, -z));
+                maid.SetRot(new Vector3(0, rotation * Mathf.Rad2Deg, 0));
+            }
+        }
+    }
+}

+ 178 - 0
src/MeidoPhotoStudio.Plugin/Managers/CameraManager.cs

@@ -0,0 +1,178 @@
+using System;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    using Input = InputManager;
+    using UInput = Input;
+    public class CameraManager : IManager
+    {
+        public const string header = "CAMERA";
+        private static readonly CameraMain mainCamera = CameraUtility.MainCamera;
+        private static readonly UltimateOrbitCamera ultimateOrbitCamera = CameraUtility.UOCamera;
+        private float defaultCameraMoveSpeed;
+        private float defaultCameraZoomSpeed;
+        private const float cameraFastMoveSpeed = 0.1f;
+        private const float cameraFastZoomSpeed = 3f;
+        private Camera subCamera;
+        private CameraInfo tempCameraInfo = new CameraInfo();
+        private const KeyCode AlphaOne = KeyCode.Alpha1;
+        public int CameraCount => cameraInfos.Length;
+        public EventHandler CameraChange;
+
+        private int currentCameraIndex;
+        public int CurrentCameraIndex
+        {
+            get => currentCameraIndex;
+            set
+            {
+                cameraInfos[currentCameraIndex].UpdateInfo(mainCamera);
+                currentCameraIndex = value;
+                LoadCameraInfo(cameraInfos[currentCameraIndex]);
+            }
+        }
+        private CameraInfo[] cameraInfos;
+
+        static CameraManager()
+        {
+            Input.Register(MpsKey.CameraLayer, KeyCode.Q, "Camera control layer");
+            Input.Register(MpsKey.CameraSave, KeyCode.S, "Save camera transform");
+            Input.Register(MpsKey.CameraLoad, KeyCode.A, "Load camera transform");
+            Input.Register(MpsKey.CameraReset, KeyCode.R, "Reset camera transform");
+        }
+
+        public CameraManager()
+        {
+            cameraInfos = new CameraInfo[5];
+            for (var i = 0; i < cameraInfos.Length; i++) cameraInfos[i] = new CameraInfo();
+            Activate();
+        }
+
+        public void Activate()
+        {
+            ultimateOrbitCamera.enabled = true;
+
+            defaultCameraMoveSpeed = ultimateOrbitCamera.moveSpeed;
+            defaultCameraZoomSpeed = ultimateOrbitCamera.zoomSpeed;
+
+            if (!MeidoPhotoStudio.EditMode) ResetCamera();
+
+            currentCameraIndex = 0;
+
+            tempCameraInfo.Reset();
+
+            for (var i = 0; i < CameraCount; i++) cameraInfos[i].Reset();
+
+            mainCamera.ForceCalcNearClip();
+
+            var subCamGo = new GameObject("subcam");
+            subCamera = subCamGo.AddComponent<Camera>();
+            subCamera.CopyFrom(mainCamera.camera);
+            subCamera.clearFlags = CameraClearFlags.Depth;
+            subCamera.cullingMask = 1 << 8;
+            subCamera.depth = 1f;
+            subCamera.transform.parent = mainCamera.transform;
+        }
+
+        public void Deactivate()
+        {
+            UnityEngine.Object.Destroy(subCamera.gameObject);
+            mainCamera.camera.backgroundColor = Color.black;
+
+            ultimateOrbitCamera.moveSpeed = defaultCameraMoveSpeed;
+            ultimateOrbitCamera.zoomSpeed = defaultCameraZoomSpeed;
+
+            if (MeidoPhotoStudio.EditMode) return;
+
+            mainCamera.Reset(CameraMain.CameraType.Target, true);
+            mainCamera.SetTargetPos(new Vector3(0.5609447f, 1.380762f, -1.382336f));
+            mainCamera.SetDistance(1.6f);
+            mainCamera.SetAroundAngle(new Vector2(245.5691f, 6.273283f));
+
+            mainCamera.ResetCalcNearClip();
+        }
+
+        public void Update()
+        {
+            if (Input.GetKey(MpsKey.CameraLayer))
+            {
+                if (Input.GetKeyDown(MpsKey.CameraSave)) SaveTempCamera();
+                else if (Input.GetKeyDown(MpsKey.CameraLoad)) LoadCameraInfo(tempCameraInfo);
+                else if (Input.GetKeyDown(MpsKey.CameraReset)) ResetCamera();
+
+                for (var i = 0; i < CameraCount; i++)
+                {
+                    if (i != CurrentCameraIndex && UInput.GetKeyDown(AlphaOne + i)) CurrentCameraIndex = i;
+                }
+            }
+
+            subCamera.fieldOfView = mainCamera.camera.fieldOfView;
+
+            var shift = Input.Shift;
+            ultimateOrbitCamera.moveSpeed = shift ? cameraFastMoveSpeed : defaultCameraMoveSpeed;
+            ultimateOrbitCamera.zoomSpeed = shift ? cameraFastZoomSpeed : defaultCameraZoomSpeed;
+        }
+
+        private void SaveTempCamera()
+        {
+            tempCameraInfo.UpdateInfo(mainCamera);
+            CameraUtility.StopAll();
+        }
+
+        public void LoadCameraInfo(CameraInfo info)
+        {
+            info.Apply(mainCamera);
+            CameraUtility.StopAll();
+            CameraChange?.Invoke(this, EventArgs.Empty);
+        }
+
+        private void ResetCamera()
+        {
+            mainCamera.Reset(CameraMain.CameraType.Target, true);
+            mainCamera.SetTargetPos(new Vector3(0f, 0.9f, 0f));
+            mainCamera.SetDistance(3f);
+            CameraChange?.Invoke(this, EventArgs.Empty);
+        }
+    }
+
+    public class CameraInfo
+    {
+        public Vector3 TargetPos { get; set; }
+        public Quaternion Angle { get; set; }
+        public float Distance { get; set; }
+        public float FOV { get; set; }
+
+        public CameraInfo() => Reset();
+
+        public static CameraInfo FromCamera(CameraMain mainCamera)
+        {
+            var info = new CameraInfo();
+            info.UpdateInfo(mainCamera);
+            return info;
+        }
+
+        public void Reset()
+        {
+            TargetPos = new Vector3(0f, 0.9f, 0f);
+            Angle = Quaternion.Euler(10f, 180f, 0f);
+            Distance = 3f;
+            FOV = 35f;
+        }
+
+        public void UpdateInfo(CameraMain camera)
+        {
+            TargetPos = camera.GetTargetPos();
+            Angle = camera.transform.rotation;
+            Distance = camera.GetDistance();
+            FOV = camera.camera.fieldOfView;
+        }
+
+        public void Apply(CameraMain camera)
+        {
+            camera.SetTargetPos(TargetPos);
+            camera.SetDistance(Distance);
+            camera.transform.rotation = Angle;
+            camera.camera.fieldOfView = FOV;
+        }
+    }
+}

+ 35 - 0
src/MeidoPhotoStudio.Plugin/Managers/EffectManager.cs

@@ -0,0 +1,35 @@
+using System;
+using System.Collections.Generic;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class EffectManager : IManager
+    {
+        public const string header = "EFFECT";
+        public const string footer = "END_EFFECT";
+        private readonly Dictionary<Type, IEffectManager> EffectManagers = new Dictionary<Type, IEffectManager>();
+
+        public T Get<T>() where T : IEffectManager
+            => EffectManagers.ContainsKey(typeof(T)) ? (T)EffectManagers[typeof(T)] : default;
+
+        public T AddManager<T>() where T : IEffectManager, new()
+        {
+            T manager = new T();
+            EffectManagers[typeof(T)] = manager;
+            manager.Activate();
+            return manager;
+        }
+
+        public void Activate()
+        {
+            foreach (IEffectManager effectManager in EffectManagers.Values) effectManager.Activate();
+        }
+
+        public void Deactivate()
+        {
+            foreach (IEffectManager effectManager in EffectManagers.Values) effectManager.Deactivate();
+        }
+
+        public void Update() { }
+    }
+}

+ 143 - 0
src/MeidoPhotoStudio.Plugin/Managers/EffectManagers/BloomEffectManager.cs

@@ -0,0 +1,143 @@
+using System.Reflection;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class BloomEffectManager : IEffectManager
+    {
+        public const string header = "EFFECT_BLOOM";
+        private const float bloomDefIntensity = 5.7f;
+        private static readonly CameraMain camera = GameMain.Instance.MainCamera;
+        private Bloom Bloom { get; set; }
+        // CMSystem's bloomValue;
+        private static int backupBloomValue;
+        private static readonly float backup_m_fBloomDefIntensity;
+        private static readonly FieldInfo m_fBloomDefIntensity
+            = Utility.GetFieldInfo<CameraMain>("m_fBloomDefIntensity");
+        private static float BloomDefIntensity
+        {
+            set => m_fBloomDefIntensity.SetValue(camera, value);
+            get => (float)m_fBloomDefIntensity.GetValue(camera);
+        }
+        private float initialIntensity;
+        private int initialBlurIterations;
+        private Color initialThresholdColour;
+        private Bloom.HDRBloomMode initialHDRBloomMode;
+        public bool Ready { get; private set; }
+        public bool Active { get; private set; }
+        private float bloomValue;
+        public float BloomValue
+        {
+            get => bloomValue;
+            set => GameMain.Instance.CMSystem.BloomValue = (int)(bloomValue = value);
+        }
+        private int blurIterations;
+        public int BlurIterations
+        {
+            get => blurIterations;
+            set => blurIterations = Bloom.bloomBlurIterations = value;
+        }
+        public float BloomThresholdColorRed
+        {
+            get => BloomThresholdColour.r;
+            set
+            {
+                Color colour = Bloom.bloomThreshholdColor;
+                BloomThresholdColour = new Color(value, colour.g, colour.b);
+            }
+        }
+        public float BloomThresholdColorGreen
+        {
+            get => BloomThresholdColour.g;
+            set
+            {
+                Color colour = Bloom.bloomThreshholdColor;
+                BloomThresholdColour = new Color(colour.r, value, colour.b);
+            }
+        }
+        public float BloomThresholdColorBlue
+        {
+            get => BloomThresholdColour.b;
+            set
+            {
+                Color colour = Bloom.bloomThreshholdColor;
+                BloomThresholdColour = new Color(colour.r, colour.g, value);
+            }
+        }
+        private Color bloomThresholdColour;
+        public Color BloomThresholdColour
+        {
+            get => bloomThresholdColour;
+            set => bloomThresholdColour = Bloom.bloomThreshholdColor = value;
+        }
+        private bool bloomHdr;
+        public bool BloomHDR
+        {
+            get => bloomHdr;
+            set
+            {
+                Bloom.hdr = value ? Bloom.HDRBloomMode.On : Bloom.HDRBloomMode.Auto;
+                bloomHdr = value;
+            }
+        }
+
+        static BloomEffectManager() => backup_m_fBloomDefIntensity = BloomDefIntensity;
+
+        public void Activate()
+        {
+            if (Bloom == null)
+            {
+                Ready = true;
+                Bloom = GameMain.Instance.MainCamera.GetComponent<Bloom>();
+                initialIntensity = bloomValue = 50f;
+                initialBlurIterations = blurIterations = Bloom.bloomBlurIterations;
+                initialThresholdColour = bloomThresholdColour = Bloom.bloomThreshholdColor;
+                initialHDRBloomMode = Bloom.hdr;
+                bloomHdr = Bloom.hdr == Bloom.HDRBloomMode.On;
+
+                backupBloomValue = GameMain.Instance.CMSystem.BloomValue;
+            }
+            SetEffectActive(false);
+        }
+
+        public void Deactivate()
+        {
+            BloomValue = initialIntensity;
+            BlurIterations = initialBlurIterations;
+            BloomThresholdColour = initialThresholdColour;
+            BloomHDR = initialHDRBloomMode == Bloom.HDRBloomMode.On;
+            BloomHDR = false;
+            Active = false;
+
+            BloomDefIntensity = backup_m_fBloomDefIntensity;
+            GameMain.Instance.CMSystem.BloomValue = backupBloomValue;
+        }
+
+        public void Reset()
+        {
+            GameMain.Instance.CMSystem.BloomValue = backupBloomValue;
+            Bloom.bloomBlurIterations = initialBlurIterations;
+            Bloom.bloomThreshholdColor = initialThresholdColour;
+            Bloom.hdr = initialHDRBloomMode;
+
+            BloomDefIntensity = backup_m_fBloomDefIntensity;
+        }
+
+        public void SetEffectActive(bool active)
+        {
+            if (Active = active)
+            {
+                backupBloomValue = GameMain.Instance.CMSystem.BloomValue;
+                GameMain.Instance.CMSystem.BloomValue = (int)BloomValue;
+                Bloom.bloomBlurIterations = BlurIterations;
+                Bloom.bloomThreshholdColor = BloomThresholdColour;
+                Bloom.hdr = BloomHDR ? Bloom.HDRBloomMode.On : Bloom.HDRBloomMode.Auto;
+
+                BloomDefIntensity = bloomDefIntensity;
+            }
+            else Reset();
+        }
+
+        public void Update() { }
+    }
+}

+ 70 - 0
src/MeidoPhotoStudio.Plugin/Managers/EffectManagers/BlurEffectManager.cs

@@ -0,0 +1,70 @@
+namespace MeidoPhotoStudio.Plugin
+{
+    public class BlurEffectManager : IEffectManager
+    {
+        public const string header = "EFFECT_BLUR";
+        private Blur Blur { get; set; }
+        public bool Ready { get; private set; }
+        public bool Active { get; private set; }
+        private float initialBlurSize;
+        private int initialBlurIterations;
+        private int initialDownsample;
+        private float blurSize;
+        public float BlurSize
+        {
+            get => blurSize;
+            set
+            {
+                blurSize = value;
+                Blur.blurSize = blurSize / 10f;
+                if (blurSize >= 3f)
+                {
+                    Blur.blurSize -= 0.3f;
+                    Blur.blurIterations = 1;
+                    Blur.downsample = 1;
+                }
+                else
+                {
+                    Blur.blurIterations = 0;
+                    Blur.downsample = 0;
+                }
+            }
+        }
+
+        public void Activate()
+        {
+            if (Blur == null)
+            {
+                Ready = true;
+                Blur = GameMain.Instance.MainCamera.GetComponent<Blur>();
+                initialBlurSize = Blur.blurSize;
+                initialBlurIterations = Blur.blurIterations;
+                initialDownsample = Blur.downsample;
+            }
+            SetEffectActive(false);
+        }
+
+        public void Deactivate()
+        {
+            BlurSize = 0f;
+            Reset();
+            Blur.enabled = false;
+            Active = false;
+        }
+
+        public void SetEffectActive(bool active)
+        {
+            if (Blur.enabled = Active = active) BlurSize = BlurSize;
+            else Reset();
+        }
+
+        public void Reset()
+        {
+            Blur.blurSize = initialBlurSize;
+            Blur.blurIterations = initialBlurIterations;
+            Blur.downsample = initialDownsample;
+        }
+
+        public void Update() { }
+    }
+}

+ 100 - 0
src/MeidoPhotoStudio.Plugin/Managers/EffectManagers/DepthOfFieldManager.cs

@@ -0,0 +1,100 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class DepthOfFieldEffectManager : IEffectManager
+    {
+        public const string header = "EFFECT_DOF";
+        private DepthOfFieldScatter DepthOfField { get; set; }
+        public bool Ready { get; private set; }
+        public bool Active { get; private set; }
+        private readonly float initialValue = 0f;
+        private float focalLength;
+        public float FocalLength
+        {
+            get => focalLength;
+            set => focalLength = DepthOfField.focalLength = value;
+        }
+
+        private float focalSize;
+        public float FocalSize
+        {
+            get => focalSize;
+            set => focalSize = DepthOfField.focalSize = value;
+        }
+        private float aperture;
+        public float Aperture
+        {
+            get => aperture;
+            set => aperture = DepthOfField.aperture = value;
+        }
+        private float maxBlurSize;
+        public float MaxBlurSize
+        {
+            get => maxBlurSize;
+            set => maxBlurSize = DepthOfField.maxBlurSize = value;
+        }
+        private bool visualizeFocus;
+        public bool VisualizeFocus
+        {
+            get => visualizeFocus;
+            set => visualizeFocus = DepthOfField.visualizeFocus = value;
+        }
+
+        public void Activate()
+        {
+            if (DepthOfField == null)
+            {
+                Ready = true;
+                DepthOfField = GameMain.Instance.MainCamera.GetOrAddComponent<DepthOfFieldScatter>();
+                if (DepthOfField.dofHdrShader == null)
+                {
+                    DepthOfField.dofHdrShader = Shader.Find("Hidden/Dof/DepthOfFieldHdr");
+                }
+                if (DepthOfField.dx11BokehShader == null)
+                {
+                    DepthOfField.dx11BokehShader = Shader.Find("Hidden/Dof/DX11Dof");
+                }
+                if (DepthOfField.dx11BokehTexture == null)
+                {
+                    DepthOfField.dx11BokehTexture = Resources.Load("Textures/hexShape") as Texture2D;
+                }
+            }
+            SetEffectActive(false);
+        }
+
+        public void Deactivate()
+        {
+            FocalLength = initialValue;
+            FocalSize = initialValue;
+            Aperture = initialValue;
+            MaxBlurSize = initialValue;
+            VisualizeFocus = false;
+            DepthOfField.enabled = false;
+            Active = false;
+        }
+
+        public void Reset()
+        {
+            DepthOfField.focalLength = initialValue;
+            DepthOfField.focalSize = initialValue;
+            DepthOfField.aperture = initialValue;
+            DepthOfField.maxBlurSize = initialValue;
+        }
+
+        public void SetEffectActive(bool active)
+        {
+            DepthOfField.enabled = active;
+            if (Active = active)
+            {
+                DepthOfField.focalLength = FocalLength;
+                DepthOfField.focalSize = FocalSize;
+                DepthOfField.aperture = Aperture;
+                DepthOfField.maxBlurSize = MaxBlurSize;
+            }
+            else Reset();
+        }
+
+        public void Update() { }
+    }
+}

+ 126 - 0
src/MeidoPhotoStudio.Plugin/Managers/EffectManagers/FogEffectManager.cs

@@ -0,0 +1,126 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class FogEffectManager : IEffectManager
+    {
+        public const string header = "EFFECT_FOG";
+        private GlobalFog Fog { get; set; }
+        public bool Ready { get; private set; }
+        public bool Active { get; private set; }
+        private readonly float initialDistance = 4f;
+        private readonly float initialDensity = 1f;
+        private readonly float initialHeightScale = 1f;
+        private readonly float initialHeight = 0f;
+        private readonly Color initialColour = Color.white;
+        private float distance;
+        public float Distance
+        {
+            get => distance;
+            set => distance = Fog.startDistance = value;
+        }
+        private float density;
+        public float Density
+        {
+            get => density;
+            set => density = Fog.globalDensity = value;
+        }
+        private float heightScale;
+        public float HeightScale
+        {
+            get => heightScale;
+            set => heightScale = Fog.heightScale = value;
+        }
+        private float height;
+        public float Height
+        {
+            get => height;
+            set => height = Fog.height = value;
+        }
+        public float FogColourRed
+        {
+            get => FogColour.r;
+            set
+            {
+                Color fogColour = FogColour;
+                FogColour = new Color(value, fogColour.g, fogColour.b);
+            }
+        }
+        public float FogColourGreen
+        {
+            get => FogColour.g;
+            set
+            {
+                Color fogColour = FogColour;
+                FogColour = new Color(fogColour.r, value, fogColour.b);
+            }
+        }
+        public float FogColourBlue
+        {
+            get => FogColour.b;
+            set
+            {
+                Color fogColour = FogColour;
+                FogColour = new Color(fogColour.r, fogColour.g, value);
+            }
+        }
+        private Color fogColour;
+        public Color FogColour
+        {
+            get => fogColour;
+            set => fogColour = Fog.globalFogColor = value;
+        }
+
+        public void Activate()
+        {
+            if (Fog == null)
+            {
+                Ready = true;
+                Fog = GameMain.Instance.MainCamera.GetOrAddComponent<GlobalFog>();
+                if (Fog.fogShader == null) Fog.fogShader = Shader.Find("Hidden/GlobalFog");
+                Distance = initialDistance;
+                Density = initialDensity;
+                HeightScale = initialHeightScale;
+                Height = initialHeight;
+                FogColour = initialColour;
+            }
+            SetEffectActive(false);
+        }
+
+        public void Deactivate()
+        {
+            Distance = initialDistance;
+            Density = initialDensity;
+            HeightScale = initialHeightScale;
+            Height = initialHeight;
+            FogColour = initialColour;
+            Fog.enabled = false;
+            Active = false;
+        }
+
+        public void Reset()
+        {
+            Fog.startDistance = initialDistance;
+            Fog.globalDensity = initialDensity;
+            Fog.heightScale = initialHeightScale;
+            Fog.height = initialHeight;
+            Fog.globalFogColor = initialColour;
+        }
+
+        public void SetEffectActive(bool active)
+        {
+            Fog.enabled = active;
+            if (Active = active)
+            {
+                Fog.startDistance = Distance;
+                Fog.globalDensity = Density;
+                Fog.heightScale = HeightScale;
+                Fog.height = Height;
+                Fog.globalFogColor = FogColour;
+            }
+            else Reset();
+        }
+
+        public void Update() { }
+    }
+}

+ 10 - 0
src/MeidoPhotoStudio.Plugin/Managers/EffectManagers/IEffectManager.cs

@@ -0,0 +1,10 @@
+namespace MeidoPhotoStudio.Plugin
+{
+    public interface IEffectManager : IManager
+    {
+        bool Ready { get; }
+        bool Active { get; }
+        void SetEffectActive(bool active);
+        void Reset();
+    }
+}

+ 32 - 0
src/MeidoPhotoStudio.Plugin/Managers/EffectManagers/SepiaToneEffectManager.cs

@@ -0,0 +1,32 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class SepiaToneEffectManger : IEffectManager
+    {
+        public const string header = "EFFECT_SEPIA";
+        private SepiaToneEffect SepiaTone { get; set; }
+        public bool Ready { get; private set; }
+        public bool Active { get; private set; }
+
+        public void Activate()
+        {
+            if (SepiaTone == null)
+            {
+                Ready = true;
+                SepiaTone = GameMain.Instance.MainCamera.GetOrAddComponent<SepiaToneEffect>();
+
+                if (SepiaTone.shader == null) SepiaTone.shader = Shader.Find("Hidden/Sepiatone Effect");
+            }
+            SetEffectActive(false);
+        }
+
+        public void Deactivate() => SetEffectActive(false);
+
+        public void SetEffectActive(bool active) => SepiaTone.enabled = Active = active;
+
+        public void Reset() { }
+
+        public void Update() { }
+    }
+}

+ 87 - 0
src/MeidoPhotoStudio.Plugin/Managers/EffectManagers/VignetteEffectManager.cs

@@ -0,0 +1,87 @@
+namespace MeidoPhotoStudio.Plugin
+{
+    public class VignetteEffectManager : IEffectManager
+    {
+        public const string header = "EFFECT_VIGNETTE";
+        private Vignetting Vignette { get; set; }
+        private float initialIntensity;
+        private float initialBlur;
+        private float initialBlurSpread;
+        private float initialChromaticAberration;
+        public bool Ready { get; private set; }
+        public bool Active { get; private set; }
+        private float intensity;
+        public float Intensity
+        {
+            get => intensity;
+            set => intensity = Vignette.intensity = value;
+        }
+        private float blur;
+        public float Blur
+        {
+            get => blur;
+            set => blur = Vignette.blur = value;
+        }
+        private float blurSpread;
+        public float BlurSpread
+        {
+            get => blurSpread;
+            set => blurSpread = Vignette.blurSpread = value;
+        }
+        private float chromaticAberration;
+        public float ChromaticAberration
+        {
+            get => chromaticAberration;
+            set => chromaticAberration = Vignette.chromaticAberration = value;
+        }
+
+        public void Activate()
+        {
+            if (Vignette == null)
+            {
+                Ready = true;
+                Vignette = GameMain.Instance.MainCamera.GetOrAddComponent<Vignetting>();
+                Vignette.mode = Vignetting.AberrationMode.Simple;
+
+                initialIntensity = Vignette.intensity;
+                initialBlur = Vignette.blur;
+                initialBlurSpread = Vignette.blurSpread;
+                initialChromaticAberration = Vignette.chromaticAberration;
+            }
+            SetEffectActive(false);
+        }
+
+        public void Deactivate()
+        {
+            Intensity = initialIntensity;
+            Blur = initialBlur;
+            BlurSpread = initialBlurSpread;
+            ChromaticAberration = initialChromaticAberration;
+            Vignette.enabled = false;
+            Active = false;
+        }
+
+        public void Reset()
+        {
+            Vignette.intensity = initialIntensity;
+            Vignette.blur = initialBlur;
+            Vignette.blurSpread = initialBlurSpread;
+            Vignette.chromaticAberration = initialChromaticAberration;
+        }
+
+        public void SetEffectActive(bool active)
+        {
+            Vignette.enabled = active;
+            if (Active = active)
+            {
+                Vignette.intensity = Intensity;
+                Vignette.blur = Blur;
+                Vignette.blurSpread = BlurSpread;
+                Vignette.chromaticAberration = ChromaticAberration;
+            }
+            else Reset();
+        }
+
+        public void Update() { }
+    }
+}

+ 144 - 0
src/MeidoPhotoStudio.Plugin/Managers/EnvironmentManager.cs

@@ -0,0 +1,144 @@
+using System;
+using UnityEngine;
+using Object = UnityEngine.Object;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    using Input = InputManager;
+    public class EnvironmentManager : IManager
+    {
+        private static readonly BgMgr bgMgr = GameMain.Instance.BgMgr;
+        public const string header = "ENVIRONMENT";
+        public const string defaultBg = "Theater";
+        private const string myRoomPrefix = "マイルーム:";
+        private static bool cubeActive;
+        public static bool CubeActive
+        {
+            get => cubeActive;
+            set
+            {
+                if (value == cubeActive) return;
+                cubeActive = value;
+                CubeActiveChange?.Invoke(null, EventArgs.Empty);
+            }
+        }
+        private static bool cubeSmall;
+        public static bool CubeSmall
+        {
+            get => cubeSmall;
+            set
+            {
+                if (value == cubeSmall) return;
+                cubeSmall = value;
+                CubeSmallChange?.Invoke(null, EventArgs.Empty);
+            }
+        }
+        private static event EventHandler CubeActiveChange;
+        private static event EventHandler CubeSmallChange;
+        private Transform bg;
+        private GameObject bgObject;
+        private DragPointBG bgDragPoint;
+        public string CurrentBgAsset { get; private set; } = defaultBg;
+        private bool bgVisible = true;
+        public bool BGVisible
+        {
+            get => bgVisible;
+            set
+            {
+                bgVisible = value;
+                bgObject.SetActive(bgVisible);
+            }
+        }
+
+        public EnvironmentManager()
+        {
+            DragPointLight.EnvironmentManager = this;
+            Activate();
+        }
+
+        public void Update() { }
+
+        public void Activate()
+        {
+            BgMgrPatcher.ChangeBgBegin += OnChangeBegin;
+            BgMgrPatcher.ChangeBgEnd += OnChangeEnd;
+
+            bgObject = bgMgr.Parent;
+
+            bgObject.SetActive(true);
+            
+            if (MeidoPhotoStudio.EditMode) UpdateBG();
+            else ChangeBackground(defaultBg);
+
+            CubeSmallChange += OnCubeSmall;
+            CubeActiveChange += OnCubeActive;
+        }
+
+        public void Deactivate()
+        {
+            BgMgrPatcher.ChangeBgBegin -= OnChangeBegin;
+            BgMgrPatcher.ChangeBgEnd -= OnChangeEnd;
+
+            DestroyDragPoint();
+            BGVisible = true;
+
+            if (MeidoPhotoStudio.EditMode) bgMgr.ChangeBg(defaultBg);
+            else
+            {
+                var isNight = GameMain.Instance.CharacterMgr.status.GetFlag("時間帯") == 3;
+                bgMgr.ChangeBg(isNight ? "ShinShitsumu_ChairRot_Night" : "ShinShitsumu_ChairRot");
+            }
+
+            if (bgMgr.BgObject) bgMgr.BgObject.transform.localScale = Vector3.one;
+
+            CubeSmallChange -= OnCubeSmall;
+            CubeActiveChange -= OnCubeActive;
+        }
+
+        public void ChangeBackground(string assetName, bool creative = false)
+        {
+            if (creative) bgMgr.ChangeBgMyRoom(assetName);
+            else bgMgr.ChangeBg(assetName);
+        }
+
+        private void AttachDragPoint(Transform bgTransform)
+        {
+            bgDragPoint = DragPoint.Make<DragPointBG>(PrimitiveType.Cube, Vector3.one * 0.12f);
+            bgDragPoint.Initialize(() => bgTransform.position, () => Vector3.zero);
+            bgDragPoint.Set(bgTransform);
+            bgDragPoint.AddGizmo();
+            bgDragPoint.ConstantScale = true;
+            bgDragPoint.gameObject.SetActive(CubeActive);
+        }
+
+        private void OnChangeBegin(object sender, EventArgs args) => DestroyDragPoint();
+
+        private void OnChangeEnd(object sender, EventArgs args) => UpdateBG();
+
+        private void UpdateBG()
+        {
+            if (!bgMgr.BgObject) return;
+
+            CurrentBgAsset = bgMgr.GetBGName();
+            if (CurrentBgAsset.StartsWith(myRoomPrefix))
+                CurrentBgAsset = CurrentBgAsset.Replace(myRoomPrefix, string.Empty);
+            bg = bgMgr.BgObject.transform;
+            AttachDragPoint(bg);
+        }
+
+        private void DestroyDragPoint()
+        {
+            if (bgDragPoint) Object.Destroy(bgDragPoint.gameObject);
+        }
+
+        private void OnCubeSmall(object sender, EventArgs args)
+        {
+            bgDragPoint.DragPointScale = CubeSmall ? DragPointGeneral.smallCube : 1f;
+        }
+
+        private void OnCubeActive(object sender, EventArgs args)
+        {
+            bgDragPoint.gameObject.SetActive(CubeActive);
+        }
+    }
+}

+ 9 - 0
src/MeidoPhotoStudio.Plugin/Managers/IManager.cs

@@ -0,0 +1,9 @@
+namespace MeidoPhotoStudio.Plugin
+{
+    public interface IManager
+    {
+        void Update();
+        void Activate();
+        void Deactivate();
+    }
+}

+ 148 - 0
src/MeidoPhotoStudio.Plugin/Managers/InputManager.cs

@@ -0,0 +1,148 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using UnityEngine;
+using BepInEx.Configuration;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public static class InputManager
+    {
+        private static InputListener inputListener;
+        private static readonly Dictionary<MpsKey, KeyCode> ActionKeys = new Dictionary<MpsKey, KeyCode>();
+        private static readonly Dictionary<MpsKey, ConfigEntry<KeyCode>> ConfigEntries
+            = new Dictionary<MpsKey, ConfigEntry<KeyCode>>();
+        public static KeyCode CurrentKeyCode { get; private set; }
+        public static bool Listening { get; private set; }
+        public static event EventHandler KeyChange;
+        public static bool Control => Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl);
+        public static bool Alt => Input.GetKey(KeyCode.LeftAlt) || Input.GetKey(KeyCode.RightAlt);
+        public static bool Shift => Input.GetKey(KeyCode.LeftShift) || Input.GetKey(KeyCode.RightShift);
+        public static readonly AcceptableValueBase controlRange;
+        public const KeyCode upperKeyCode = KeyCode.F15;
+        public const string configHeader = "Controls";
+
+        static InputManager() => controlRange = new AcceptableValueRange<KeyCode>(default, upperKeyCode);
+
+        public static void Register(MpsKey action, KeyCode key, string description)
+        {
+            key = Clamp(key, default, upperKeyCode);
+            if (ConfigEntries.ContainsKey(action)) Rebind(action, key);
+            else
+            {
+                ConfigDescription configDescription = new ConfigDescription(description, controlRange);
+                ConfigEntries[action] = Configuration.Config.Bind(
+                    configHeader, action.ToString(), key, configDescription
+                );
+                key = ConfigEntries[action].Value;
+                ActionKeys[action] = key;
+            }
+        }
+
+        public static void Rebind(MpsKey action, KeyCode key)
+        {
+            key = Clamp(key, default, upperKeyCode);
+            if (ConfigEntries.ContainsKey(action)) ConfigEntries[action].Value = key;
+            ActionKeys[action] = key;
+        }
+
+        public static KeyCode Clamp(KeyCode value, KeyCode min, KeyCode max)
+            => value < min ? min : value > max ? max : value;
+
+        public static KeyCode GetActionKey(MpsKey action)
+            => ActionKeys.TryGetValue(action, out KeyCode keyCode) ? keyCode : default;
+
+        public static void StartListening()
+        {
+            if (inputListener == null) inputListener = new GameObject().AddComponent<InputListener>();
+            else if (inputListener.gameObject.activeSelf) StopListening();
+
+            inputListener.gameObject.SetActive(true);
+            inputListener.KeyChange += OnKeyChange;
+            CurrentKeyCode = KeyCode.None;
+            Listening = true;
+        }
+
+        public static void StopListening()
+        {
+            if (!inputListener || !inputListener.gameObject.activeSelf) return;
+            inputListener.gameObject.SetActive(false);
+            inputListener.KeyChange -= OnKeyChange;
+            CurrentKeyCode = KeyCode.None;
+            Listening = false;
+            Input.ResetInputAxes();
+        }
+
+        public static bool GetKey(MpsKey action)
+            => !Listening && ActionKeys.ContainsKey(action) && Input.GetKey(ActionKeys[action]);
+
+        public static bool GetKeyDown(MpsKey action)
+            => !Listening && ActionKeys.ContainsKey(action) && Input.GetKeyDown(ActionKeys[action]);
+
+        public static void Deactivate()
+        {
+            StopListening();
+            GameObject.Destroy(inputListener?.gameObject);
+            inputListener = null;
+        }
+
+        private static void OnKeyChange(object sender, KeyChangeEventArgs args)
+        {
+            CurrentKeyCode = args.Key;
+            KeyChange?.Invoke(null, EventArgs.Empty);
+            StopListening();
+        }
+
+        /* Listener taken from https://forum.unity.com/threads/find-out-which-key-was-pressed.385250/ */
+        private class InputListener : MonoBehaviour
+        {
+            private static readonly KeyCode[] keyCodes;
+            public event EventHandler<KeyChangeEventArgs> KeyChange;
+
+            static InputListener()
+            {
+                keyCodes = Enum.GetValues(typeof(KeyCode))
+                    .Cast<KeyCode>()
+                    .Where(keyCode => keyCode <= upperKeyCode)
+                    .ToArray();
+            }
+
+            private void Awake() => DontDestroyOnLoad(this);
+
+            private void Update()
+            {
+                if (Input.anyKeyDown)
+                {
+                    foreach (KeyCode key in keyCodes)
+                    {
+                        if (Input.GetKeyDown(key))
+                        {
+                            KeyChange?.Invoke(this, new KeyChangeEventArgs(key));
+                            break;
+                        }
+                    }
+                }
+            }
+        }
+
+        private class KeyChangeEventArgs : EventArgs
+        {
+            public KeyCode Key { get; }
+            public KeyChangeEventArgs(KeyCode key) => Key = key;
+        }
+    }
+
+    public enum MpsKey
+    {
+        // MeidoPhotoStudio
+        Activate, Screenshot, ToggleUI, ToggleMessage,
+        // MeidoManager
+        MeidoUndressing,
+        // Camera
+        CameraLayer, CameraReset, CameraSave, CameraLoad,
+        // Dragpoint
+        DragSelect, DragDelete, DragMove, DragRotate, DragScale, DragFinger,
+        // Scene management
+        SaveScene, LoadScene, OpenSceneManager
+    }
+}

+ 173 - 0
src/MeidoPhotoStudio.Plugin/Managers/LightManager.cs

@@ -0,0 +1,173 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class LightManager : IManager
+    {
+        public const string header = "LIGHT";
+        private static bool cubeActive = true;
+        public static bool CubeActive
+        {
+            get => cubeActive;
+            set
+            {
+                if (value != cubeActive)
+                {
+                    cubeActive = value;
+                    CubeActiveChange?.Invoke(null, EventArgs.Empty);
+                }
+            }
+        }
+        private static event EventHandler CubeActiveChange;
+        private readonly List<DragPointLight> lightList = new List<DragPointLight>();
+        private int selectedLightIndex;
+        public int SelectedLightIndex
+        {
+            get => selectedLightIndex;
+            set
+            {
+                selectedLightIndex = Mathf.Clamp(value, 0, lightList.Count - 1);
+                lightList[SelectedLightIndex].IsActiveLight = true;
+            }
+        }
+        public string[] LightNameList => lightList.Select(light => LightName(light.Name)).ToArray();
+        public string ActiveLightName => LightName(lightList[SelectedLightIndex].Name);
+        public DragPointLight CurrentLight => lightList[SelectedLightIndex];
+        public event EventHandler Rotate;
+        public event EventHandler Scale;
+        public event EventHandler ListModified;
+        public event EventHandler Select;
+
+        public LightManager() => Activate();
+
+        public void Activate()
+        {
+            GameMain.Instance.MainCamera.GetComponent<Camera>().backgroundColor = Color.black;
+            AddLight(GameMain.Instance.MainLight.gameObject, true);
+            CubeActiveChange += OnCubeActive;
+        }
+
+        public void Deactivate()
+        {
+            for (int i = 0; i < lightList.Count; i++)
+            {
+                DestroyLight(lightList[i]);
+            }
+            selectedLightIndex = 0;
+            lightList.Clear();
+
+            GameMain.Instance.MainLight.Reset();
+
+            Light mainLight = GameMain.Instance.MainLight.GetComponent<Light>();
+            mainLight.type = LightType.Directional;
+            DragPointLight.SetLightProperties(mainLight, new LightProperty());
+            CubeActiveChange -= OnCubeActive;
+        }
+
+        public void Update() { }
+
+        public void AddLight(GameObject lightGo = null, bool isMain = false)
+        {
+            GameObject go = lightGo ?? new GameObject("MPS Light");
+            DragPointLight light = DragPoint.Make<DragPointLight>(PrimitiveType.Cube, Vector3.one * 0.12f);
+            light.Initialize(() => go.transform.position, () => go.transform.eulerAngles);
+            light.Set(go.transform);
+            light.IsMain = isMain;
+
+            light.Rotate += OnRotate;
+            light.Scale += OnScale;
+            light.Delete += OnDelete;
+            light.Select += OnSelect;
+
+            lightList.Add(light);
+
+            CurrentLight.IsActiveLight = false;
+            SelectedLightIndex = lightList.Count;
+            OnListModified();
+        }
+
+        public void DeleteActiveLight()
+        {
+            if (selectedLightIndex == 0) return;
+
+            DeleteLight(SelectedLightIndex);
+        }
+
+        public void DeleteLight(int lightIndex, bool noUpdate = false)
+        {
+            if (lightIndex == 0) return;
+
+            DestroyLight(lightList[lightIndex]);
+            lightList.RemoveAt(lightIndex);
+
+            if (lightIndex <= SelectedLightIndex) SelectedLightIndex--;
+
+            if (noUpdate) return;
+            OnListModified();
+        }
+
+        public void SetColourModeActive(bool isColourMode) => lightList[0].IsColourMode = isColourMode;
+
+        public void ClearLights()
+        {
+            for (int i = lightList.Count - 1; i > 0; i--) DeleteLight(i);
+            selectedLightIndex = 0;
+        }
+
+        private void DestroyLight(DragPointLight light)
+        {
+            if (light == null) return;
+            light.Rotate -= OnRotate;
+            light.Scale -= OnScale;
+            light.Delete -= OnDelete;
+            light.Select -= OnSelect;
+            GameObject.Destroy(light.gameObject);
+        }
+
+        private string LightName(string name) => Translation.Get("lightType", name);
+
+        private void OnDelete(object sender, EventArgs args)
+        {
+            DragPointLight theLight = (DragPointLight)sender;
+            for (int i = 1; i < lightList.Count; i++)
+            {
+                DragPointLight light = lightList[i];
+                if (light == theLight)
+                {
+                    DeleteLight(i);
+                    return;
+                }
+            }
+        }
+
+        private void OnRotate(object sender, EventArgs args) => OnTransformEvent((DragPointLight)sender, Rotate);
+
+        private void OnScale(object sender, EventArgs args) => OnTransformEvent((DragPointLight)sender, Scale);
+
+        private void OnTransformEvent(DragPointLight light, EventHandler handler)
+        {
+            if (light.IsActiveLight) handler?.Invoke(this, EventArgs.Empty);
+        }
+
+        private void OnSelect(object sender, EventArgs args)
+        {
+            DragPointLight theLight = (DragPointLight)sender;
+            int select = lightList.FindIndex(light => light == theLight);
+            if (select >= 0)
+            {
+                SelectedLightIndex = select;
+                Select?.Invoke(this, EventArgs.Empty);
+            }
+        }
+
+        private void OnListModified() => ListModified?.Invoke(this, EventArgs.Empty);
+
+        private void OnCubeActive(object sender, EventArgs args)
+        {
+            foreach (DragPointLight dragPoint in lightList) dragPoint.gameObject.SetActive(CubeActive);
+        }
+    }
+}

+ 333 - 0
src/MeidoPhotoStudio.Plugin/Managers/MeidoManager.cs

@@ -0,0 +1,333 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class MeidoManager : IManager
+    {
+        public const string header = "MEIDO";
+        private static readonly CharacterMgr characterMgr = GameMain.Instance.CharacterMgr;
+        private int undress;
+        private int numberOfMeidos;
+        private int tempEditMaidIndex = -1;
+        public Meido[] Meidos { get; private set; }
+        public HashSet<int> SelectedMeidoSet { get; } = new HashSet<int>();
+        public List<int> SelectMeidoList { get; } = new List<int>();
+        public List<Meido> ActiveMeidoList { get; } = new List<Meido>();
+        public Meido ActiveMeido => ActiveMeidoList.Count > 0 ? ActiveMeidoList[SelectedMeido] : null;
+        public Meido EditMeido => tempEditMaidIndex >= 0 ? Meidos[tempEditMaidIndex] : Meidos[EditMaidIndex];
+        public bool HasActiveMeido => ActiveMeido != null;
+        public event EventHandler<MeidoUpdateEventArgs> UpdateMeido;
+        public event EventHandler EndCallMeidos;
+        public event EventHandler BeginCallMeidos;
+        private int selectedMeido;
+        public int SelectedMeido
+        {
+            get => selectedMeido;
+            private set => selectedMeido = Utility.Bound(value, 0, ActiveMeidoList.Count - 1);
+        }
+        public int EditMaidIndex { get; private set; }
+        public bool Busy => ActiveMeidoList.Any(meido => meido.Busy);
+        private bool globalGravity;
+        public bool GlobalGravity
+        {
+            get => globalGravity;
+            set
+            {
+                globalGravity = value;
+
+                if (!HasActiveMeido) return;
+
+                Meido activeMeido = ActiveMeido;
+                int activeMeidoSlot = activeMeido.Slot;
+
+                foreach (Meido meido in ActiveMeidoList)
+                {
+                    if (meido.Slot != activeMeidoSlot)
+                    {
+                        meido.HairGravityActive = value && activeMeido.HairGravityActive;
+                        meido.SkirtGravityActive = value && activeMeido.SkirtGravityActive;
+                    }
+                }
+            }
+        }
+
+        static MeidoManager() => InputManager.Register(MpsKey.MeidoUndressing, KeyCode.H, "All maid undressing");
+
+        public MeidoManager() => Activate();
+
+        public void ChangeMaid(int index) => OnUpdateMeido(null, new MeidoUpdateEventArgs(index));
+
+        public void Activate()
+        {
+            GameMain.Instance.CharacterMgr.ResetCharaPosAll();
+            numberOfMeidos = characterMgr.GetStockMaidCount();
+            Meidos = new Meido[numberOfMeidos];
+
+            tempEditMaidIndex = -1;
+
+            for (int stockMaidIndex = 0; stockMaidIndex < numberOfMeidos; stockMaidIndex++)
+            {
+                Meidos[stockMaidIndex] = new Meido(stockMaidIndex);
+            }
+
+            if (MeidoPhotoStudio.EditMode)
+            {
+                Maid editMaid = GameMain.Instance.CharacterMgr.GetMaid(0);
+                EditMaidIndex = Array.FindIndex(Meidos, meido => meido.Maid.status.guid == editMaid.status.guid);
+                EditMeido.IsEditMaid = true;
+
+                var editOkCancel = UTY.GetChildObject(GameObject.Find("UI Root"), "OkCancel")
+                    .GetComponent<EditOkCancel>();
+
+                // Ensure MPS resets editor state before setting maid
+                EditOkCancel.OnClick newEditOnClick = () => SetEditMaid(Meidos[EditMaidIndex]);
+                newEditOnClick += OkCancelDelegate();
+
+                Utility.SetFieldValue(editOkCancel, "m_dgOnClickOk", newEditOnClick);
+
+                // Only for setting custom parts placement animation just in case body was changed before activating MPS
+                SetEditMaid(Meidos[EditMaidIndex]);
+            }
+
+            ClearSelectList();
+        }
+
+        public void Deactivate()
+        {
+            foreach (Meido meido in Meidos)
+            {
+                meido.UpdateMeido -= OnUpdateMeido;
+                meido.GravityMove -= OnGravityMove;
+                meido.Deactivate();
+            }
+
+            ActiveMeidoList.Clear();
+
+            if (MeidoPhotoStudio.EditMode && !GameMain.Instance.MainCamera.IsFadeOut())
+            {
+                Meido meido = Meidos[EditMaidIndex];
+                meido.Maid.Visible = true;
+                meido.Stop = false;
+                meido.EyeToCam = true;
+
+                SetEditMaid(meido);
+
+                // Restore original OK button functionality
+                GameObject okButton = UTY.GetChildObjectNoError(GameObject.Find("UI Root"), "OkCancel");
+                if (okButton)
+                {
+                    EditOkCancel editOkCancel = okButton.GetComponent<EditOkCancel>();
+                    Utility.SetFieldValue(editOkCancel, "m_dgOnClickOk", OkCancelDelegate());
+                }
+            }
+        }
+
+        private EditOkCancel.OnClick OkCancelDelegate()
+        {
+            return (EditOkCancel.OnClick)Delegate
+                .CreateDelegate(typeof(EditOkCancel.OnClick), SceneEdit.Instance, "OnEditOk");
+        }
+
+        public void Update()
+        {
+            if (InputManager.GetKeyDown(MpsKey.MeidoUndressing)) UndressAll();
+        }
+
+        private void UnloadMeidos()
+        {
+            SelectedMeido = 0;
+            foreach (Meido meido in ActiveMeidoList)
+            {
+                meido.UpdateMeido -= OnUpdateMeido;
+                meido.GravityMove -= OnGravityMove;
+                meido.Unload();
+            }
+            ActiveMeidoList.Clear();
+        }
+
+        public void CallMeidos()
+        {
+            BeginCallMeidos?.Invoke(this, EventArgs.Empty);
+
+            bool moreThanEditMaid = ActiveMeidoList.Count > 1;
+
+            UnloadMeidos();
+
+            if (SelectMeidoList.Count == 0)
+            {
+                OnEndCallMeidos(this, EventArgs.Empty);
+                return;
+            }
+
+            void callMeidos() => GameMain.Instance.StartCoroutine(LoadMeidos());
+
+            if (MeidoPhotoStudio.EditMode && !moreThanEditMaid && SelectMeidoList.Count == 1) callMeidos();
+            else GameMain.Instance.MainCamera.FadeOut(0.01f, f_bSkipable: false, f_dg: callMeidos);
+        }
+
+        private System.Collections.IEnumerator LoadMeidos()
+        {
+            foreach (int slot in SelectMeidoList) ActiveMeidoList.Add(Meidos[slot]);
+
+            for (int i = 0; i < ActiveMeidoList.Count; i++) ActiveMeidoList[i].Load(i);
+
+            while (Busy) yield return null;
+
+            yield return new WaitForEndOfFrame();
+
+            OnEndCallMeidos(this, EventArgs.Empty);
+        }
+
+        public void SelectMeido(int index)
+        {
+            if (SelectedMeidoSet.Contains(index))
+            {
+                if (!MeidoPhotoStudio.EditMode || index != EditMaidIndex)
+                {
+                    SelectedMeidoSet.Remove(index);
+                    SelectMeidoList.Remove(index);
+                }
+            }
+            else
+            {
+                SelectedMeidoSet.Add(index);
+                SelectMeidoList.Add(index);
+            }
+        }
+
+        public void ClearSelectList()
+        {
+            SelectedMeidoSet.Clear();
+            SelectMeidoList.Clear();
+            if (MeidoPhotoStudio.EditMode)
+            {
+                SelectedMeidoSet.Add(EditMaidIndex);
+                SelectMeidoList.Add(EditMaidIndex);
+            }
+        }
+
+        public void SetEditMaid(Meido meido)
+        {
+            if (!MeidoPhotoStudio.EditMode) return;
+
+            EditMeido.IsEditMaid = false;
+
+            tempEditMaidIndex = meido.Maid.status.guid == Meidos[EditMaidIndex].Maid.status.guid
+                ? -1
+                : Array.FindIndex(Meidos, maid => maid.Maid.status.guid == meido.Maid.status.guid);
+
+            EditMeido.IsEditMaid = true;
+
+            Maid newEditMaid = EditMeido.Maid;
+
+            GameObject uiRoot = GameObject.Find("UI Root");
+
+            var presetCtrl = UTY.GetChildObjectNoError(uiRoot, "PresetPanel")?.GetComponent<PresetCtrl>();
+            var presetButton = UTY.GetChildObjectNoError(uiRoot, "PresetButtonPanel")?.GetComponent<PresetButtonCtrl>();
+            var profileCtrl = UTY.GetChildObjectNoError(uiRoot, "ProfilePanel")?.GetComponent<ProfileCtrl>();
+            var customPartsWindow = UTY.GetChildObjectNoError(uiRoot, "Window/CustomPartsWindow")
+                ?.GetComponent<SceneEditWindow.CustomPartsWindow>();
+
+            if (!(presetCtrl || presetButton || profileCtrl || customPartsWindow)) return;
+
+            // Preset application
+            Utility.SetFieldValue(presetCtrl, "m_maid", newEditMaid);
+
+            // Preset saving
+            Utility.SetFieldValue(presetButton, "m_maid", newEditMaid);
+
+            // Maid profile (name, description, experience etc)
+            Utility.SetFieldValue(profileCtrl, "m_maidStatus", newEditMaid.status);
+
+            // Accessory/Parts placement
+            Utility.SetFieldValue(customPartsWindow, "maid", newEditMaid);
+
+            // Stopping maid animation and head movement when customizing parts placement
+            Utility.SetFieldValue(customPartsWindow, "animation", newEditMaid.GetAnimation());
+
+            // Clothing/body in general and maybe other things
+            Utility.SetFieldValue(SceneEdit.Instance, "m_maid", newEditMaid);
+
+            // Body status, parts colours and maybe more
+            Utility.GetFieldValue<CharacterMgr, Maid[]>(
+                GameMain.Instance.CharacterMgr, "m_gcActiveMaid"
+            )[0] = newEditMaid;
+        }
+
+        public Meido GetMeido(string guid)
+        {
+            return string.IsNullOrEmpty(guid) ? null : ActiveMeidoList.Find(meido => meido.Maid.status.guid == guid);
+        }
+
+        public Meido GetMeido(int activeIndex)
+        {
+            return activeIndex >= 0 && activeIndex < ActiveMeidoList.Count ? ActiveMeidoList[activeIndex] : null;
+        }
+
+        public void PlaceMeidos(string placementType)
+        {
+            MaidPlacementUtility.ApplyPlacement(placementType, ActiveMeidoList);
+        }
+
+        private void UndressAll()
+        {
+            if (!HasActiveMeido) return;
+
+            undress = ++undress % Enum.GetNames(typeof(Meido.Mask)).Length;
+
+            foreach (Meido activeMeido in ActiveMeidoList) activeMeido.SetMaskMode((Meido.Mask)undress);
+
+            UpdateMeido?.Invoke(ActiveMeido, new MeidoUpdateEventArgs(SelectedMeido));
+        }
+
+        private void OnUpdateMeido(object sender, MeidoUpdateEventArgs args)
+        {
+            if (!args.IsEmpty) SelectedMeido = args.SelectedMeido;
+            UpdateMeido?.Invoke(ActiveMeido, args);
+        }
+
+        private void OnEndCallMeidos(object sender, EventArgs args)
+        {
+            GameMain.Instance.MainCamera.FadeIn(1f);
+            EndCallMeidos?.Invoke(this, EventArgs.Empty);
+            foreach (Meido meido in ActiveMeidoList)
+            {
+                meido.UpdateMeido += OnUpdateMeido;
+                meido.GravityMove += OnGravityMove;
+            }
+
+            if (MeidoPhotoStudio.EditMode && tempEditMaidIndex >= 0 && !SelectedMeidoSet.Contains(tempEditMaidIndex))
+            {
+                SetEditMaid(Meidos[EditMaidIndex]);
+            }
+        }
+
+        private void OnGravityMove(object sender, GravityEventArgs args)
+        {
+            if (!GlobalGravity) return;
+
+            foreach (Meido meido in ActiveMeidoList)
+            {
+                meido.ApplyGravity(args.LocalPosition, args.IsSkirt);
+            }
+        }
+    }
+
+    public class MeidoUpdateEventArgs : EventArgs
+    {
+        public static new MeidoUpdateEventArgs Empty { get; } = new MeidoUpdateEventArgs(-1);
+        public bool IsEmpty => (this == Empty) || (SelectedMeido == -1 && !FromMeido && IsBody);
+        public int SelectedMeido { get; }
+        public bool IsBody { get; }
+        public bool FromMeido { get; }
+        public MeidoUpdateEventArgs(int meidoIndex = -1, bool fromMaid = false, bool isBody = true)
+        {
+            SelectedMeido = meidoIndex;
+            IsBody = isBody;
+            FromMeido = fromMaid;
+        }
+    }
+}

+ 100 - 0
src/MeidoPhotoStudio.Plugin/Managers/MessageWindowManager.cs

@@ -0,0 +1,100 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class MessageWindowManager : IManager
+    {
+        public const string header = "TEXTBOX";
+        public static readonly SliderProp fontBounds = new SliderProp(25f, 60f);
+        private static GameObject sysRoot;
+        private readonly MessageClass msgClass;
+        private readonly MessageWindowMgr msgWnd;
+        private readonly UILabel msgLabel;
+        private readonly UILabel nameLabel;
+        private readonly GameObject msgGameObject;
+        public bool ShowingMessage { get; private set; }
+        public string MessageName { get; private set; } = string.Empty;
+        public string MessageText { get; private set; } = string.Empty;
+
+        public int FontSize
+        {
+            get => msgLabel.fontSize;
+            set => msgLabel.fontSize = (int)Mathf.Clamp(value, fontBounds.Left, fontBounds.Right);
+        }
+
+        static MessageWindowManager()
+        {
+            InputManager.Register(MpsKey.ToggleMessage, KeyCode.M, "Show/hide message box");
+        }
+
+        public MessageWindowManager()
+        {
+            sysRoot = GameObject.Find("__GameMain__/SystemUI Root");
+            msgWnd = GameMain.Instance.MsgWnd;
+            msgGameObject = sysRoot.transform.Find("MessageWindowPanel").gameObject;
+            msgClass = new MessageClass(msgGameObject, msgWnd);
+            nameLabel = UTY.GetChildObject(msgGameObject, "MessageViewer/MsgParent/SpeakerName/Name")
+                .GetComponent<UILabel>();
+            msgLabel = UTY.GetChildObject(msgGameObject, "MessageViewer/MsgParent/Message")
+                .GetComponent<UILabel>();
+            Utility.SetFieldValue(msgClass, "message_label_", msgLabel);
+            Utility.SetFieldValue(msgClass, "name_label_", nameLabel);
+            Activate();
+        }
+
+        public void Activate() => SetPhotoMessageWindowActive(true);
+
+        public void Deactivate()
+        {
+            msgWnd.CloseMessageWindowPanel();
+            SetPhotoMessageWindowActive(false);
+        }
+
+        public void Update() { }
+
+        private void SetPhotoMessageWindowActive(bool active)
+        {
+            UTY.GetChildObject(msgGameObject, "MessageViewer/MsgParent/MessageBox").SetActive(active);
+            UTY.GetChildObject(msgGameObject, "MessageViewer/MsgParent/Hitret")
+                .GetComponent<UISprite>().enabled = !active;
+            nameLabel.gameObject.SetActive(active);
+            msgLabel.gameObject.SetActive(active);
+
+            Transform transform = sysRoot.transform.Find("MessageWindowPanel/MessageViewer/MsgParent/Buttons");
+            var msgButtons = new[]
+            {
+                MessageWindowMgr.MessageWindowUnderButton.Skip,
+                MessageWindowMgr.MessageWindowUnderButton.Auto,
+                MessageWindowMgr.MessageWindowUnderButton.Voice,
+                MessageWindowMgr.MessageWindowUnderButton.BackLog,
+                MessageWindowMgr.MessageWindowUnderButton.Config
+            };
+            foreach (MessageWindowMgr.MessageWindowUnderButton msgButton in msgButtons)
+            {
+                transform.Find(msgButton.ToString()).gameObject.SetActive(!active);
+            }
+
+            if (!msgClass.subtitles_manager_) return;
+
+            msgClass.subtitles_manager_.visible = false;
+            msgClass.subtitles_manager_ = null;
+        }
+
+        public void ShowMessage(string name, string message)
+        {
+            MessageName = name;
+            MessageText = message;
+            ShowingMessage = true;
+            msgWnd.OpenMessageWindowPanel();
+            msgLabel.ProcessText();
+            msgClass.SetText(name, message, "", 0, AudioSourceMgr.Type.System);
+            msgClass.FinishChAnime();
+        }
+
+        public void CloseMessagePanel()
+        {
+            ShowingMessage = false;
+            msgWnd.CloseMessageWindowPanel();
+        }
+    }
+}

+ 320 - 0
src/MeidoPhotoStudio.Plugin/Managers/PropManager.cs

@@ -0,0 +1,320 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using BepInEx.Configuration;
+using UnityEngine;
+using Object = UnityEngine.Object;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    using static ModelUtility;
+
+    public class PropManager : IManager
+    {
+        public const string header = "PROP";
+        private static readonly ConfigEntry<bool> modItemsOnly;
+        private static bool cubeActive = true;
+        private static Dictionary<string, string> modFileToFullPath;
+
+        private static Dictionary<string, string> ModFileToFullPath
+        {
+            get
+            {
+                if (modFileToFullPath != null) return modFileToFullPath;
+
+                string[] modFiles = Menu.GetModFiles();
+                modFileToFullPath = new Dictionary<string, string>(modFiles.Length, StringComparer.OrdinalIgnoreCase);
+
+                foreach (var mod in modFiles)
+                {
+                    var key = Path.GetFileName(mod);
+                    if (!modFileToFullPath.ContainsKey(key)) modFileToFullPath[key] = mod;
+                }
+
+                return modFileToFullPath;
+            }
+        }
+
+        public static bool CubeActive
+        {
+            get => cubeActive;
+            set
+            {
+                if (value != cubeActive)
+                {
+                    cubeActive = value;
+                    CubeActiveChange?.Invoke(null, EventArgs.Empty);
+                }
+            }
+        }
+
+        private static bool cubeSmall;
+
+        public static bool CubeSmall
+        {
+            get => cubeSmall;
+            set
+            {
+                if (value != cubeSmall)
+                {
+                    cubeSmall = value;
+                    CubeSmallChange?.Invoke(null, EventArgs.Empty);
+                }
+            }
+        }
+
+        private static event EventHandler CubeActiveChange;
+        private static event EventHandler CubeSmallChange;
+        public static bool ModItemsOnly => modItemsOnly.Value;
+        private readonly List<DragPointProp> propList = new List<DragPointProp>();
+
+        public string[] PropNameList => propList.Count == 0
+            ? new[] { Translation.Get("systemMessage", "noProps") }
+            : propList.Select(prop => prop.Name).ToArray();
+
+        public int PropCount => propList.Count;
+        private int currentPropIndex;
+        private MeidoManager meidoManager;
+
+        public int CurrentPropIndex
+        {
+            get => currentPropIndex;
+            set
+            {
+                if (PropCount == 0)
+                {
+                    currentPropIndex = 0;
+                    return;
+                }
+
+                if ((uint) value >= (uint) PropCount) throw new ArgumentOutOfRangeException(nameof(value));
+
+                if (currentPropIndex == value) return;
+
+                currentPropIndex = value;
+                PropSelectionChange?.Invoke(this, EventArgs.Empty);
+            }
+        }
+
+        public DragPointProp CurrentProp => PropCount == 0 ? null : propList[CurrentPropIndex];
+        public event EventHandler PropSelectionChange;
+        public event EventHandler FromPropSelect;
+        public event EventHandler PropListChange;
+
+        static PropManager() => modItemsOnly = Configuration.Config.Bind(
+            "Prop", "ModItemsOnly",
+            false,
+            "Disable waiting for and loading base game clothing"
+        );
+
+        public PropManager(MeidoManager meidoManager)
+        {
+            this.meidoManager = meidoManager;
+            meidoManager.BeginCallMeidos += OnBeginCallMeidos;
+            meidoManager.EndCallMeidos += OnEndCallMeidos;
+            Activate();
+        }
+
+        public bool AddFromPropInfo(PropInfo propInfo)
+        {
+            switch (propInfo.Type)
+            {
+                case PropInfo.PropType.Mod:
+                    ModItem modItem;
+                    if (!string.IsNullOrEmpty(propInfo.SubFilename))
+                    {
+                        modItem = ModItem.OfficialMod(ModFileToFullPath[propInfo.Filename]);
+                        modItem.BaseMenuFile = propInfo.SubFilename;
+                    }
+                    else modItem = ModItem.Mod(propInfo.Filename);
+
+                    return AddModProp(modItem);
+                case PropInfo.PropType.MyRoom:
+                    return AddMyRoomProp(new MyRoomItem { ID = propInfo.MyRoomID, PrefabName = propInfo.Filename });
+                case PropInfo.PropType.Bg:
+                    return AddBgProp(propInfo.Filename);
+                case PropInfo.PropType.Odogu:
+                    return AddGameProp(propInfo.Filename);
+                default: throw new ArgumentOutOfRangeException();
+            }
+        }
+
+        public bool AddModProp(ModItem modItem)
+        {
+            var model = LoadMenuModel(modItem);
+            if (!model) return false;
+
+            var name = modItem.MenuFile;
+            if (modItem.IsOfficialMod) name = Path.GetFileName(name) + ".menu"; // add '.menu' for partsedit support
+            model.name = name;
+
+            var dragPoint = AttachDragPoint(model);
+            dragPoint.Info = PropInfo.FromModItem(modItem);
+
+            AddProp(dragPoint);
+
+            return true;
+        }
+
+        public bool AddMyRoomProp(MyRoomItem myRoomItem)
+        {
+            var model = LoadMyRoomModel(myRoomItem);
+            if (!model) return false;
+
+            model.name = Translation.Get("myRoomPropNames", myRoomItem.PrefabName);
+
+            var dragPoint = AttachDragPoint(model);
+            dragPoint.Info = PropInfo.FromMyRoom(myRoomItem);
+
+            AddProp(dragPoint);
+
+            return true;
+        }
+
+        public bool AddBgProp(string assetName)
+        {
+            var model = LoadBgModel(assetName);
+            if (!model) return false;
+
+            model.name = Translation.Get("bgNames", assetName);
+
+            var dragPoint = AttachDragPoint(model);
+            dragPoint.Info = PropInfo.FromBg(assetName);
+
+            AddProp(dragPoint);
+
+            return true;
+        }
+
+        public bool AddGameProp(string assetName)
+        {
+            var isMenu = assetName.EndsWith(".menu");
+            var model = isMenu ? LoadMenuModel(assetName) : LoadGameModel(assetName);
+            if (!model) return false;
+
+            model.name = Translation.Get("propNames", isMenu ? Utility.HandItemToOdogu(assetName) : assetName, !isMenu);
+
+            var dragPoint = AttachDragPoint(model);
+            dragPoint.Info = PropInfo.FromGameProp(assetName);
+
+            AddProp(dragPoint);
+
+            return true;
+        }
+
+        public void CopyProp(int propIndex)
+        {
+            if ((uint) propIndex >= (uint) PropCount) throw new ArgumentOutOfRangeException(nameof(propIndex));
+
+            AddFromPropInfo(propList[propIndex].Info);
+        }
+
+        public void DeleteAllProps()
+        {
+            foreach (var prop in propList) DestroyProp(prop);
+            propList.Clear();
+            CurrentPropIndex = 0;
+            EmitPropListChange();
+        }
+
+        public void RemoveProp(int index)
+        {
+            if ((uint) index >= (uint) PropCount) throw new ArgumentOutOfRangeException(nameof(index));
+
+            DestroyProp(propList[index]);
+            propList.RemoveAt(index);
+            CurrentPropIndex = Utility.Bound(CurrentPropIndex, 0, PropCount - 1);
+            EmitPropListChange();
+        }
+
+        public void AttachProp(DragPointProp prop, AttachPoint point, int index)
+        {
+            if ((uint) index >= (uint) meidoManager.ActiveMeidoList.Count) return;
+
+            var meido = meidoManager.ActiveMeidoList[index];
+
+            prop.AttachTo(meido, point);
+        }
+
+        private DragPointProp AttachDragPoint(GameObject model)
+        {
+            var dragPoint = DragPoint.Make<DragPointProp>(PrimitiveType.Cube, Vector3.one * 0.12f);
+            dragPoint.Initialize(() => model.transform.position, () => Vector3.zero);
+            dragPoint.Set(model.transform);
+            dragPoint.AddGizmo(0.45f, CustomGizmo.GizmoMode.World);
+            dragPoint.ConstantScale = true;
+            dragPoint.DragPointScale = CubeSmall ? DragPointGeneral.smallCube : 1f;
+            dragPoint.Delete += OnDeleteProp;
+            dragPoint.Select += OnSelectProp;
+            return dragPoint;
+        }
+
+        private void AddProp(DragPointProp dragPoint)
+        {
+            propList.Add(dragPoint);
+            EmitPropListChange();
+        }
+
+        private void DestroyProp(DragPointProp prop)
+        {
+            if (!prop) return;
+
+            prop.Delete -= OnDeleteProp;
+            prop.Select -= OnSelectProp;
+            Object.Destroy(prop.gameObject);
+        }
+
+        private void EmitPropListChange() => PropListChange?.Invoke(this, EventArgs.Empty);
+
+        private void OnBeginCallMeidos(object sender, EventArgs args)
+        {
+            foreach (var prop in propList.Where(p => p.AttachPointInfo.AttachPoint != AttachPoint.None))
+                prop.DetachTemporary();
+        }
+
+        private void OnEndCallMeidos(object sender, EventArgs args)
+        {
+            foreach (var prop in propList.Where(p => p.AttachPointInfo.AttachPoint != AttachPoint.None))
+            {
+                var info = prop.AttachPointInfo;
+                var meido = meidoManager.GetMeido(info.MaidGuid);
+                prop.AttachTo(meido, info.AttachPoint, meido == null);
+            }
+        }
+
+        private void OnDeleteProp(object sender, EventArgs args)
+            => RemoveProp(propList.IndexOf((DragPointProp) sender));
+
+        private void OnSelectProp(object sender, EventArgs args)
+        {
+            CurrentPropIndex = propList.IndexOf((DragPointProp) sender);
+            FromPropSelect?.Invoke(this, EventArgs.Empty);
+        }
+
+        private void OnCubeSmall(object sender, EventArgs args)
+        {
+            foreach (var dragPoint in propList) dragPoint.DragPointScale = CubeSmall ? DragPointGeneral.smallCube : 1f;
+        }
+
+        private void OnCubeActive(object sender, EventArgs args)
+        {
+            foreach (var dragPoint in propList) dragPoint.gameObject.SetActive(CubeActive);
+        }
+
+        public void Update() { }
+
+        public void Activate()
+        {
+            CubeSmallChange += OnCubeSmall;
+            CubeActiveChange += OnCubeActive;
+        }
+
+        public void Deactivate()
+        {
+            DeleteAllProps();
+            CubeSmallChange -= OnCubeSmall;
+            CubeActiveChange -= OnCubeActive;
+        }
+    }
+}

+ 332 - 0
src/MeidoPhotoStudio.Plugin/Managers/SceneManager.cs

@@ -0,0 +1,332 @@
+using System;
+using System.IO;
+using System.Collections.Generic;
+using System.Linq;
+using UnityEngine;
+using BepInEx.Configuration;
+using Object = UnityEngine.Object;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    using Input = InputManager;
+    public class SceneManager : IManager
+    {
+
+        private static byte[] tempSceneData;
+        private static string TempScenePath => Path.Combine(Constants.configPath, "mpstempscene");
+        public static bool Busy { get; private set; }
+        public static readonly Vector2 sceneDimensions = new Vector2(480, 270);
+        private static readonly ConfigEntry<bool> sortDescending;
+        private static readonly ConfigEntry<SortMode> currentSortMode;
+        private readonly MeidoPhotoStudio meidoPhotoStudio;
+        private int SortDirection => SortDescending ? -1 : 1;
+        public bool Initialized { get; private set; }
+        public bool KankyoMode { get; set; }
+        public bool SortDescending
+        {
+            get => sortDescending.Value;
+            set => sortDescending.Value = value;
+        }
+        public List<MPSScene> SceneList { get; } = new();
+        public int CurrentDirectoryIndex { get; private set; } = -1;
+        public string CurrentDirectoryName => CurrentDirectoryList[CurrentDirectoryIndex];
+        public List<string> CurrentDirectoryList
+            => KankyoMode ? Constants.KankyoDirectoryList : Constants.SceneDirectoryList;
+        public string CurrentBasePath => KankyoMode ? Constants.kankyoPath : Constants.scenesPath;
+        public string CurrentScenesDirectory
+            => CurrentDirectoryIndex == 0 ? CurrentBasePath : Path.Combine(CurrentBasePath, CurrentDirectoryName);
+        public SortMode CurrentSortMode
+        {
+            get => currentSortMode.Value;
+            private set => currentSortMode.Value = value;
+        }
+        public int CurrentSceneIndex { get; private set; } = -1;
+        public MPSScene CurrentScene => SceneList.Count == 0 ? null : SceneList[CurrentSceneIndex];
+        public enum SortMode
+        {
+            Name, DateCreated, DateModified
+        }
+
+        static SceneManager()
+        {
+            sortDescending = Configuration.Config.Bind(
+                "SceneManager", "SortDescending",
+                false,
+                "Sort scenes descending (Z-A)"
+            );
+
+            currentSortMode = Configuration.Config.Bind(
+                "SceneManager", "SortMode",
+                SortMode.Name,
+                "Scene sorting mode"
+            );
+
+            Input.Register(MpsKey.OpenSceneManager, KeyCode.F8, "Hide/show scene manager");
+            Input.Register(MpsKey.SaveScene, KeyCode.S, "Quick save scene");
+            Input.Register(MpsKey.LoadScene, KeyCode.A, "Load quick saved scene");
+        }
+
+        public SceneManager(MeidoPhotoStudio meidoPhotoStudio)
+        {
+            this.meidoPhotoStudio = meidoPhotoStudio;
+            Activate();
+        }
+
+        public void Activate() { }
+
+        public void Initialize()
+        {
+            if (!Initialized)
+            {
+                Initialized = true;
+                SelectDirectory(0);
+            }
+        }
+
+        public void Deactivate() => ClearSceneList();
+
+        public void Update()
+        {
+            if (Input.Control)
+            {
+                if (Input.GetKeyDown(MpsKey.SaveScene)) QuickSaveScene();
+                else if (Input.GetKeyDown(MpsKey.LoadScene)) QuickLoadScene();
+            }
+        }
+
+        public void DeleteDirectory()
+        {
+            if (Directory.Exists(CurrentScenesDirectory)) Directory.Delete(CurrentScenesDirectory, true);
+
+            CurrentDirectoryList.RemoveAt(CurrentDirectoryIndex);
+            CurrentDirectoryIndex = Mathf.Clamp(CurrentDirectoryIndex, 0, CurrentDirectoryList.Count - 1);
+            UpdateSceneList();
+        }
+
+        public void OverwriteScene() => SaveScene(overwrite: true);
+
+        public void ToggleKankyoMode()
+        {
+            KankyoMode = !KankyoMode;
+            CurrentDirectoryIndex = 0;
+            UpdateSceneList();
+        }
+
+        public void SaveScene(bool overwrite = false)
+        {
+            if (Busy) return;
+            Busy = true;
+
+            if (!Directory.Exists(CurrentScenesDirectory)) Directory.CreateDirectory(CurrentScenesDirectory);
+
+            MeidoPhotoStudio.NotifyRawScreenshot += SaveScene;
+
+            MeidoPhotoStudio.TakeScreenshot(new ScreenshotEventArgs() { InMemory = true });
+
+            void SaveScene(object sender, ScreenshotEventArgs args)
+            {
+                MeidoPhotoStudio.NotifyRawScreenshot -= SaveScene;
+                SaveSceneToFile(args.Screenshot, overwrite);
+            }
+        }
+
+        public void SelectDirectory(int directoryIndex)
+        {
+            directoryIndex = Mathf.Clamp(directoryIndex, 0, CurrentDirectoryList.Count - 1);
+
+            if (directoryIndex == CurrentDirectoryIndex) return;
+
+            CurrentDirectoryIndex = directoryIndex;
+
+            UpdateSceneList();
+        }
+
+        public void SelectScene(int sceneIndex)
+        {
+            CurrentSceneIndex = Mathf.Clamp(sceneIndex, 0, SceneList.Count - 1);
+            CurrentScene.Preload();
+        }
+
+        public void AddDirectory(string directoryName)
+        {
+            directoryName = Utility.SanitizePathPortion(directoryName);
+
+            if (!CurrentDirectoryList.Contains(directoryName, StringComparer.InvariantCultureIgnoreCase))
+            {
+                string finalPath = Path.Combine(CurrentBasePath, directoryName);
+                string fullPath = Path.GetFullPath(finalPath);
+
+                if (!fullPath.StartsWith(CurrentBasePath))
+                {
+                    string baseDirectoryName = KankyoMode ? Constants.kankyoDirectory : Constants.sceneDirectory;
+                    Utility.LogError($"Could not add directory to {baseDirectoryName}. Path is invalid: '{fullPath}'");
+                    return;
+                }
+
+                CurrentDirectoryList.Add(directoryName);
+                Directory.CreateDirectory(finalPath);
+
+                UpdateDirectoryList();
+                CurrentDirectoryIndex = CurrentDirectoryList.IndexOf(directoryName);
+
+                UpdateSceneList();
+            }
+        }
+
+        public void Refresh()
+        {
+            if (!Directory.Exists(CurrentScenesDirectory)) CurrentDirectoryIndex = 0;
+
+            if (KankyoMode) Constants.InitializeKankyoDirectories();
+            else Constants.InitializeSceneDirectories();
+
+            UpdateSceneList();
+        }
+
+        public void SortScenes(SortMode sortMode)
+        {
+            CurrentSortMode = sortMode;
+            Comparison<MPSScene> comparator = CurrentSortMode switch
+            {
+                SortMode.DateModified => SortByDateModified,
+                SortMode.DateCreated => SortByDateCreated,
+                _ => SortByName,
+            };
+            SceneList.Sort(comparator);
+        }
+
+        public void DeleteScene()
+        {
+            if (CurrentScene.FileInfo.Exists)
+            {
+                CurrentScene.FileInfo.Delete();
+            }
+            SceneList.RemoveAt(CurrentSceneIndex);
+            CurrentSceneIndex = Mathf.Clamp(CurrentSceneIndex, 0, SceneList.Count - 1);
+        }
+
+        public void LoadScene(MPSScene scene) => meidoPhotoStudio.LoadScene(scene.Data);
+
+        private int SortByName(MPSScene a, MPSScene b)
+        {
+            return SortDirection * LexicographicStringComparer.Comparison(a.FileInfo.Name, b.FileInfo.Name);
+        }
+
+        private int SortByDateCreated(MPSScene a, MPSScene b)
+        {
+            return SortDirection * DateTime.Compare(a.FileInfo.CreationTime, b.FileInfo.CreationTime);
+        }
+
+        private int SortByDateModified(MPSScene a, MPSScene b)
+        {
+            return SortDirection * DateTime.Compare(a.FileInfo.LastWriteTime, b.FileInfo.LastWriteTime);
+        }
+
+        private void UpdateSceneList()
+        {
+            ClearSceneList();
+
+            if (!Directory.Exists(CurrentScenesDirectory))
+            {
+                Directory.CreateDirectory(CurrentScenesDirectory);
+            }
+
+            foreach (string filename in Directory.GetFiles(CurrentScenesDirectory))
+            {
+                if (Path.GetExtension(filename) == ".png") SceneList.Add(new MPSScene(filename));
+            }
+
+            SortScenes(CurrentSortMode);
+
+            CurrentSceneIndex = Mathf.Clamp(CurrentSceneIndex, 0, SceneList.Count - 1);
+        }
+
+        private void UpdateDirectoryList()
+        {
+            string baseDirectoryName = KankyoMode ? Constants.kankyoDirectory : Constants.sceneDirectory;
+            CurrentDirectoryList.Sort((a, b)
+                => a.Equals(baseDirectoryName, StringComparison.InvariantCultureIgnoreCase) 
+                    ? -1 : LexicographicStringComparer.Comparison(a, b));
+        }
+
+        private void ClearSceneList()
+        {
+            foreach (MPSScene scene in SceneList) scene.Destroy();
+            SceneList.Clear();
+        }
+
+        private void QuickSaveScene()
+        {
+            if (Busy) return;
+            
+            var data = meidoPhotoStudio.SaveScene();
+            if (data == null) return;
+
+            tempSceneData = data;
+            
+            File.WriteAllBytes(TempScenePath, data);
+        }
+
+        private void QuickLoadScene()
+        {
+            if (Busy) return;
+
+            if (tempSceneData == null)
+            {
+                if (File.Exists(TempScenePath)) tempSceneData = File.ReadAllBytes(TempScenePath);
+                else return;
+            }
+
+            meidoPhotoStudio.LoadScene(tempSceneData);
+        }
+
+        private void SaveSceneToFile(Texture2D screenshot, bool overwrite = false)
+        {
+            Busy = true;
+
+            byte[] sceneData = meidoPhotoStudio.SaveScene(KankyoMode);
+
+            if (sceneData != null)
+            {
+                string scenePrefix = KankyoMode ? "mpskankyo" : "mpsscene";
+                string fileName = $"{scenePrefix}{Utility.Timestamp}.png";
+                string savePath = Path.Combine(CurrentScenesDirectory, fileName);
+
+                Utility.ResizeToFit(screenshot, (int) sceneDimensions.x, (int) sceneDimensions.y);
+
+                try
+                {
+                    if (overwrite && CurrentScene?.FileInfo != null) savePath = CurrentScene.FileInfo.FullName;
+                    else overwrite = false;
+
+                    using (FileStream fileStream = File.Create(savePath))
+                    {
+                        byte[] encodedPng = screenshot.EncodeToPNG();
+                        fileStream.Write(encodedPng, 0, encodedPng.Length);
+                        fileStream.Write(sceneData, 0, sceneData.Length);
+                    }
+
+                    if (overwrite)
+                    {
+                        File.SetCreationTime(savePath, CurrentScene.FileInfo.CreationTime);
+                        CurrentScene.Destroy();
+                        SceneList.RemoveAt(CurrentSceneIndex);
+                    }
+                }
+                catch (Exception e)
+                {
+                    Utility.LogError($"Failed to save scene to disk because {e.Message}\n{e.StackTrace}");
+                    Object.DestroyImmediate(screenshot);
+                    Busy = false;
+                    return;
+                }
+
+                SceneList.Add(new MPSScene(savePath, screenshot));
+                SortScenes(CurrentSortMode);
+            }
+            else Object.DestroyImmediate(screenshot);
+
+            Busy = false;
+        }
+    }
+}

+ 51 - 0
src/MeidoPhotoStudio.Plugin/Managers/WindowManager.cs

@@ -0,0 +1,51 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    using static Constants;
+    public class WindowManager : IManager
+    {
+        private readonly Dictionary<Window, BaseWindow> Windows = new Dictionary<Window, BaseWindow>();
+        public BaseWindow this[Window id]
+        {
+            get => Windows[id];
+            set
+            {
+                Windows[id] = value;
+                Windows[id].Activate();
+            }
+        }
+
+        public WindowManager() => InputManager.Register(MpsKey.ToggleUI, KeyCode.Tab, "Show/hide all UI");
+
+        public void DrawWindow(BaseWindow window)
+        {
+            if (window.Visible)
+            {
+                GUIStyle windowStyle = new GUIStyle(GUI.skin.box);
+                window.WindowRect = GUI.Window(window.windowID, window.WindowRect, window.GUIFunc, "", windowStyle);
+            }
+        }
+
+        public void DrawWindows()
+        {
+            foreach (BaseWindow window in Windows.Values) DrawWindow(window);
+        }
+
+        public void Update()
+        {
+            foreach (BaseWindow window in Windows.Values) window.Update();
+        }
+
+        public void Activate()
+        {
+            foreach (BaseWindow window in Windows.Values) window.Activate();
+        }
+
+        public void Deactivate()
+        {
+            foreach (BaseWindow window in Windows.Values) window.Deactivate();
+        }
+    }
+}

+ 87 - 0
src/MeidoPhotoStudio.Plugin/Meido/IK/DragPointFinger.cs

@@ -0,0 +1,87 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    using Input = InputManager;
+    public class DragPointFinger : DragPointMeido
+    {
+        private static readonly Color dragpointColour = new Color(0.1f, 0.4f, 0.95f, defaultAlpha);
+        private readonly TBody.IKCMO IK = new TBody.IKCMO();
+        private readonly Quaternion[] jointRotation = new Quaternion[2];
+        private IKCtrlData ikCtrlData;
+        private Transform[] ikChain;
+        private bool baseFinger;
+
+        public override void Set(Transform myObject)
+        {
+            base.Set(myObject);
+            string parentName = myObject.parent.name.Split(' ')[2];
+            // Base finger names have the form 'FingerN' or 'ToeN' where N is a natural number
+            baseFinger = (parentName.Length == 7) || (parentName.Length == 4);
+            ikChain = new Transform[2] {
+                myObject.parent,
+                myObject
+            };
+            ikCtrlData = IkCtrlData;
+        }
+
+        private void SetRotation(int joint)
+        {
+            Vector3 rotation = jointRotation[joint].eulerAngles;
+            rotation.z = ikChain[joint].localEulerAngles.z;
+            ikChain[joint].localRotation = Quaternion.Euler(rotation);
+        }
+
+        protected override void ApplyDragType()
+        {
+            if (baseFinger && CurrentDragType == DragType.RotLocalY) ApplyProperties(true, true, false);
+            else if (CurrentDragType == DragType.MoveXZ) ApplyProperties(true, true, false);
+            else ApplyProperties(false, false, false);
+            ApplyColour(dragpointColour);
+        }
+
+        protected override void UpdateDragType()
+        {
+            CurrentDragType = Input.GetKey(MpsKey.DragFinger)
+                ? Input.Shift
+                    ? DragType.RotLocalY
+                    : DragType.MoveXZ
+                : DragType.None;
+        }
+
+        protected override void OnMouseDown()
+        {
+            base.OnMouseDown();
+            jointRotation[jointUpper] = ikChain[jointUpper].localRotation;
+            jointRotation[jointMiddle] = ikChain[jointMiddle].localRotation;
+            InitializeIK(IK, ikChain[jointUpper], ikChain[jointUpper], ikChain[jointMiddle]);
+        }
+
+        protected override void Drag()
+        {
+            if (isPlaying) meido.Stop = true;
+
+            if (CurrentDragType == DragType.MoveXZ)
+            {
+                Porc(IK, ikCtrlData, ikChain[jointUpper], ikChain[jointUpper], ikChain[jointMiddle]);
+                if (!baseFinger)
+                {
+                    SetRotation(jointUpper);
+                    SetRotation(jointMiddle);
+                }
+                else
+                {
+                    jointRotation[jointUpper] = ikChain[jointUpper].localRotation;
+                    jointRotation[jointMiddle] = ikChain[jointMiddle].localRotation;
+                }
+            }
+            else if (CurrentDragType == DragType.RotLocalY)
+            {
+                Vector3 mouseDelta = MouseDelta();
+
+                ikChain[jointUpper].localRotation = jointRotation[jointUpper];
+                ikChain[jointUpper].Rotate(Vector3.right * (mouseDelta.x / 1.5f));
+            }
+        }
+    }
+}

+ 112 - 0
src/MeidoPhotoStudio.Plugin/Meido/IK/DragPointHead.cs

@@ -0,0 +1,112 @@
+using System;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    using Input = InputManager;
+    public class DragPointHead : DragPointMeido
+    {
+        private Quaternion headRotation;
+        private Vector3 eyeRotationL;
+        private Vector3 eyeRotationR;
+        public event EventHandler Select;
+        public bool IsIK { get; set; }
+
+        protected override void ApplyDragType()
+        {
+            if (IsBone)
+            {
+                DragType current = CurrentDragType;
+                bool active = current == DragType.MoveY || current == DragType.MoveXZ || current == DragType.Select;
+                ApplyProperties(active, false, false);
+            }
+            else ApplyProperties(CurrentDragType != DragType.None, false, false);
+        }
+
+        protected override void UpdateDragType()
+        {
+            bool shift = Input.Shift;
+            bool alt = Input.Alt;
+            if (alt && Input.Control)
+            {
+                // eyes
+                CurrentDragType = shift ? DragType.MoveY : DragType.MoveXZ;
+            }
+            else if (alt)
+            {
+                // head
+                CurrentDragType = shift ? DragType.RotLocalY : DragType.RotLocalXZ;
+            }
+            else if (Input.GetKey(MpsKey.DragSelect))
+            {
+                CurrentDragType = DragType.Select;
+            }
+            else
+            {
+                CurrentDragType = DragType.None;
+            }
+        }
+
+        protected override void OnMouseDown()
+        {
+            base.OnMouseDown();
+
+            if (CurrentDragType == DragType.Select) Select?.Invoke(this, EventArgs.Empty);
+
+            headRotation = MyObject.rotation;
+
+            eyeRotationL = meido.Body.quaDefEyeL.eulerAngles;
+            eyeRotationR = meido.Body.quaDefEyeR.eulerAngles;
+        }
+
+        protected override void OnDoubleClick()
+        {
+            if (CurrentDragType == DragType.MoveXZ || CurrentDragType == DragType.MoveY)
+            {
+                meido.Body.quaDefEyeL = meido.DefaultEyeRotL;
+                meido.Body.quaDefEyeR = meido.DefaultEyeRotR;
+            }
+            else if (CurrentDragType == DragType.RotLocalY || CurrentDragType == DragType.RotLocalXZ)
+            {
+                meido.FreeLook = !meido.FreeLook;
+            }
+        }
+
+        protected override void Drag()
+        {
+            if (IsIK || CurrentDragType == DragType.Select) return;
+
+            if (!(CurrentDragType == DragType.MoveXZ || CurrentDragType == DragType.MoveY) && isPlaying)
+            {
+                meido.Stop = true;
+            }
+
+            Vector3 mouseDelta = MouseDelta();
+
+            if (CurrentDragType == DragType.RotLocalXZ)
+            {
+                MyObject.rotation = headRotation;
+                MyObject.Rotate(camera.transform.forward, -mouseDelta.x / 3f, Space.World);
+                MyObject.Rotate(camera.transform.right, mouseDelta.y / 3f, Space.World);
+            }
+
+            if (CurrentDragType == DragType.RotLocalY)
+            {
+                MyObject.rotation = headRotation;
+                MyObject.Rotate(Vector3.right * mouseDelta.x / 3f);
+            }
+
+            if (CurrentDragType == DragType.MoveXZ || CurrentDragType == DragType.MoveY)
+            {
+                int inv = CurrentDragType == DragType.MoveY ? -1 : 1;
+
+                meido.Body.quaDefEyeL.eulerAngles = new Vector3(
+                    eyeRotationL.x, eyeRotationL.y - (mouseDelta.x / 10f), eyeRotationL.z - (mouseDelta.y / 10f)
+                );
+                meido.Body.quaDefEyeR.eulerAngles = new Vector3(
+                    eyeRotationR.x, eyeRotationR.y + (inv * mouseDelta.x / 10f), eyeRotationR.z + (mouseDelta.y / 10f)
+                );
+            }
+        }
+    }
+}

+ 52 - 0
src/MeidoPhotoStudio.Plugin/Meido/IK/DragPointPelvis.cs

@@ -0,0 +1,52 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    using Input = InputManager;
+    public class DragPointPelvis : DragPointMeido
+    {
+        private Quaternion pelvisRotation;
+
+        protected override void ApplyDragType()
+        {
+            if (CurrentDragType == DragType.Ignore) ApplyProperties();
+            else if (IsBone) ApplyProperties(false, false, false);
+            else ApplyProperties(CurrentDragType != DragType.None, false, false);
+        }
+
+        protected override void UpdateDragType()
+        {
+            CurrentDragType = Input.Alt && !Input.Control
+                ? Input.Shift ? DragType.RotLocalY : DragType.RotLocalXZ
+                : OtherDragType() ? DragType.Ignore : DragType.None;
+        }
+
+        protected override void OnMouseDown()
+        {
+            base.OnMouseDown();
+            pelvisRotation = MyObject.rotation;
+        }
+
+        protected override void Drag()
+        {
+            if (CurrentDragType == DragType.None) return;
+
+            if (isPlaying) meido.Stop = true;
+
+            Vector3 mouseDelta = MouseDelta();
+
+            if (CurrentDragType == DragType.RotLocalXZ)
+            {
+                MyObject.rotation = pelvisRotation;
+                MyObject.Rotate(camera.transform.forward, mouseDelta.x / 6f, Space.World);
+                MyObject.Rotate(camera.transform.right, mouseDelta.y / 4f, Space.World);
+            }
+
+            if (CurrentDragType == DragType.RotLocalY)
+            {
+                MyObject.rotation = pelvisRotation;
+                MyObject.Rotate(Vector3.right * (mouseDelta.x / 2.2f));
+            }
+        }
+    }
+}

+ 106 - 0
src/MeidoPhotoStudio.Plugin/Meido/IK/DragPointSpine.cs

@@ -0,0 +1,106 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    using Input = InputManager;
+    public class DragPointSpine : DragPointMeido
+    {
+        private Quaternion spineRotation;
+        private bool isHip;
+        private bool isThigh;
+        private bool isHead;
+
+        public override void AddGizmo(float scale = 0.25f, CustomGizmo.GizmoMode mode = CustomGizmo.GizmoMode.Local)
+        {
+            base.AddGizmo(scale, mode);
+            if (isHead) Gizmo.GizmoDrag += (s, a) => meido.HeadToCam = false;
+        }
+
+        public override void Set(Transform myObject)
+        {
+            base.Set(myObject);
+            isHip = myObject.name == "Bip01";
+            isThigh = myObject.name.EndsWith("Thigh");
+            isHead = myObject.name.EndsWith("Head");
+        }
+
+        protected override void ApplyDragType()
+        {
+            DragType current = CurrentDragType;
+            if (IsBone && current != DragType.Ignore)
+            {
+                if (!isHead && current == DragType.RotLocalXZ) ApplyProperties(false, false, isThigh);
+                else if (!isThigh && (current == DragType.MoveY)) ApplyProperties(isHip, isHip, !isHip);
+                else if (!isThigh && !isHead && (current == DragType.RotLocalY)) ApplyProperties(!isHip, !isHip, isHip);
+                else ApplyProperties(!isThigh, !isThigh, false);
+            }
+            else ApplyProperties(false, false, false);
+        }
+
+        protected override void UpdateDragType()
+        {
+            bool shift = Input.Shift;
+            bool alt = Input.Alt;
+
+            if (OtherDragType()) CurrentDragType = DragType.Ignore;
+            else if (isThigh && !Input.Control && alt && shift)
+            {
+                // gizmo thigh rotation
+                CurrentDragType = DragType.RotLocalXZ;
+            }
+            else if (alt)
+            {
+                CurrentDragType = DragType.Ignore;
+            }
+            else if (shift)
+            {
+                CurrentDragType = DragType.RotLocalY;
+            }
+            else if (Input.Control)
+            {
+                // hip y transform and spine gizmo rotation
+                CurrentDragType = DragType.MoveY;
+            }
+            else
+            {
+                CurrentDragType = DragType.None;
+            }
+        }
+
+        protected override void OnMouseDown()
+        {
+            base.OnMouseDown();
+            spineRotation = MyObject.rotation;
+        }
+
+        protected override void Drag()
+        {
+            if (isPlaying) meido.Stop = true;
+
+            Vector3 mouseDelta = MouseDelta();
+
+            if (CurrentDragType == DragType.None)
+            {
+                if (isHead) meido.HeadToCam = false;
+
+                MyObject.rotation = spineRotation;
+                MyObject.Rotate(camera.transform.forward, -mouseDelta.x / 4.5f, Space.World);
+                MyObject.Rotate(camera.transform.right, mouseDelta.y / 3f, Space.World);
+            }
+
+            if (CurrentDragType == DragType.RotLocalY)
+            {
+                if (isHead) meido.HeadToCam = false;
+
+                MyObject.rotation = spineRotation;
+                MyObject.Rotate(Vector3.right * mouseDelta.x / 4f);
+            }
+
+            if (CurrentDragType == DragType.MoveY)
+            {
+                Vector3 cursorPosition = CursorPosition();
+                MyObject.position = new Vector3(MyObject.position.x, cursorPosition.y, MyObject.position.z);
+            }
+        }
+    }
+}

+ 77 - 0
src/MeidoPhotoStudio.Plugin/Meido/IK/DragPointTorso.cs

@@ -0,0 +1,77 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    using Input = InputManager;
+    public class DragPointTorso : DragPointMeido
+    {
+        private static readonly float[] blah = new[] { 0.03f, 0.1f, 0.09f, 0.07f };
+        private static readonly float[] something = new[] { 0.08f, 0.15f };
+        private readonly Quaternion[] spineRotation = new Quaternion[4];
+        private readonly Transform[] spine = new Transform[4];
+
+        public override void Set(Transform myObject)
+        {
+            base.Set(myObject);
+            Transform spine = myObject;
+            for (int i = 0; i < this.spine.Length; i++)
+            {
+                this.spine[i] = spine;
+                spine = spine.parent;
+            }
+        }
+
+        protected override void ApplyDragType()
+        {
+            if (CurrentDragType == DragType.Ignore) ApplyProperties();
+            else if (IsBone) ApplyProperties(false, false, false);
+            else ApplyProperties(CurrentDragType != DragType.None, false, false);
+        }
+
+        protected override void UpdateDragType()
+        {
+            CurrentDragType = Input.Alt && !Input.Control
+                ? Input.Shift ? DragType.RotLocalY : DragType.RotLocalXZ
+                : OtherDragType() ? DragType.Ignore : DragType.None;
+        }
+
+        protected override void OnMouseDown()
+        {
+            base.OnMouseDown();
+            for (int i = 0; i < spine.Length; i++)
+            {
+                spineRotation[i] = spine[i].localRotation;
+            }
+        }
+
+        protected override void Drag()
+        {
+            if (CurrentDragType == DragType.None) return;
+
+            if (isPlaying) meido.Stop = true;
+
+            Vector3 mouseDelta = MouseDelta();
+
+            if (CurrentDragType == DragType.RotLocalXZ)
+            {
+                for (int i = 0; i < spine.Length; i++)
+                {
+                    spine[i].localRotation = spineRotation[i];
+                    spine[i].Rotate(
+                        camera.transform.forward, -mouseDelta.x / 1.5f * blah[i], Space.World
+                    );
+                    spine[i].Rotate(camera.transform.right, mouseDelta.y * blah[i], Space.World);
+                }
+            }
+
+            if (CurrentDragType == DragType.RotLocalY)
+            {
+                for (int i = 0; i < spine.Length; i++)
+                {
+                    spine[i].localRotation = spineRotation[i];
+                    spine[i].Rotate(Vector3.right * (mouseDelta.x / 1.5f * something[i / 2]));
+                }
+            }
+        }
+    }
+}

+ 39 - 0
src/MeidoPhotoStudio.Plugin/Meido/IK/IK Chain/DragPointChain.cs

@@ -0,0 +1,39 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public abstract class DragPointChain : DragPointMeido
+    {
+        protected readonly TBody.IKCMO IK = new TBody.IKCMO();
+        protected readonly Quaternion[] jointRotation = new Quaternion[3];
+        protected IKCtrlData ikCtrlData;
+        protected Transform[] ikChain;
+
+        public override void Set(Transform myObject)
+        {
+            base.Set(myObject);
+
+            ikChain = new Transform[] {
+                myObject.parent,
+                myObject.parent,
+                myObject
+            };
+
+            ikCtrlData = IkCtrlData;
+        }
+
+        protected void InitializeRotation()
+        {
+            for (int i = 0; i < jointRotation.Length; i++) jointRotation[i] = ikChain[i].localRotation;
+        }
+
+        protected override void OnMouseDown()
+        {
+            base.OnMouseDown();
+
+            InitializeRotation();
+
+            InitializeIK(IK, ikChain[jointUpper], ikChain[jointMiddle], ikChain[jointLower]);
+        }
+    }
+}

+ 111 - 0
src/MeidoPhotoStudio.Plugin/Meido/IK/IK Chain/DragPointLimb.cs

@@ -0,0 +1,111 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    using Input = InputManager;
+    public class DragPointLimb : DragPointChain
+    {
+        private int foot = 1;
+        private bool isLower;
+        private bool isMiddle;
+        private bool isUpper;
+        public override bool IsBone
+        {
+            set
+            {
+                base.IsBone = value;
+                BaseScale = isBone ? boneScale : OriginalScale;
+            }
+        }
+
+        public override void Set(Transform myObject)
+        {
+            base.Set(myObject);
+
+            string name = myObject.name;
+
+            foot = name.EndsWith("Foot") ? -1 : 1;
+            isLower = name.EndsWith("Hand") || foot == -1;
+            isMiddle = name.EndsWith("Calf") || name.EndsWith("Forearm");
+            isUpper = !isMiddle && !isLower;
+
+            if (isLower) ikChain[0] = ikChain[0].parent;
+        }
+
+        protected override void ApplyDragType()
+        {
+            DragType current = CurrentDragType;
+            bool isBone = IsBone;
+            if (CurrentDragType == DragType.Ignore) ApplyProperties();
+            else if (current == DragType.RotLocalXZ)
+            {
+                if (isLower) ApplyProperties(!isBone, false, isBone);
+                else ApplyProperties();
+            }
+            else if (current == DragType.RotLocalY)
+            {
+                if (isLower || isMiddle) ApplyProperties(!isBone, false, false);
+                else if (isUpper) ApplyProperties(false, false, isBone);
+                else ApplyProperties();
+            }
+            else if (current == DragType.RotY)
+            {
+                if (isMiddle) ApplyProperties(false, false, isBone);
+                else ApplyProperties();
+            }
+            else if (current == DragType.MoveXZ)
+            {
+                if (isLower) ApplyProperties(true, isBone, false);
+                else ApplyProperties();
+            }
+            else ApplyProperties(true, isBone, false);
+        }
+
+        protected override void UpdateDragType()
+        {
+            bool control = Input.Control;
+            bool alt = Input.Alt;
+            // Check for DragMove so that hand dragpoint is not in the way
+            if (OtherDragType()) CurrentDragType = DragType.Ignore;
+            else if (control && !Input.GetKey(MpsKey.DragMove))
+            {
+                if (alt) CurrentDragType = DragType.RotY;
+                else CurrentDragType = DragType.MoveXZ;
+            }
+            else if (alt) CurrentDragType = Input.Shift ? DragType.RotLocalY : DragType.RotLocalXZ;
+            else CurrentDragType = Input.Shift ? DragType.Ignore : DragType.None;
+        }
+
+        protected override void Drag()
+        {
+            if (isPlaying) meido.Stop = true;
+
+            bool altRotation = CurrentDragType == DragType.MoveXZ || CurrentDragType == DragType.RotY;
+
+            if (CurrentDragType == DragType.None || altRotation)
+            {
+                int upperJoint = altRotation ? jointMiddle : jointUpper;
+
+                Porc(IK, ikCtrlData, ikChain[upperJoint], ikChain[jointMiddle], ikChain[jointLower]);
+
+                InitializeRotation();
+            }
+
+            Vector3 mouseDelta = MouseDelta();
+
+            if (CurrentDragType == DragType.RotLocalY)
+            {
+                int joint = isMiddle ? jointUpper : jointLower;
+                ikChain[joint].localRotation = jointRotation[joint];
+                ikChain[joint].Rotate(Vector3.right * (-mouseDelta.x / 1.5f));
+            }
+
+            if (CurrentDragType == DragType.RotLocalXZ)
+            {
+                ikChain[jointLower].localRotation = jointRotation[jointLower];
+                ikChain[jointLower].Rotate(Vector3.up * (foot * mouseDelta.x / 1.5f));
+                ikChain[jointLower].Rotate(Vector3.forward * (foot * mouseDelta.y / 1.5f));
+            }
+        }
+    }
+}

+ 57 - 0
src/MeidoPhotoStudio.Plugin/Meido/IK/IK Chain/DragPointMune.cs

@@ -0,0 +1,57 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    using Input = InputManager;
+    public class DragPointMune : DragPointChain
+    {
+        private bool isMuneL;
+        private int inv = 1;
+
+        public override void Set(Transform myObject)
+        {
+            base.Set(myObject);
+            isMuneL = myObject.name[5] == 'L'; // Mune_L_Sub
+            if (isMuneL) inv *= -1;
+        }
+
+        protected override void ApplyDragType() => ApplyProperties(CurrentDragType != DragType.None, false, false);
+
+        protected override void OnMouseDown()
+        {
+            base.OnMouseDown();
+
+            meido.SetMune(false, isMuneL);
+        }
+
+        protected override void OnDoubleClick()
+        {
+            if (CurrentDragType != DragType.None) meido.SetMune(true, isMuneL);
+        }
+
+        protected override void UpdateDragType()
+        {
+            if (Input.Control && Input.Alt) CurrentDragType = Input.Shift ? DragType.RotLocalY : DragType.RotLocalXZ;
+            else CurrentDragType = DragType.None;
+        }
+
+        protected override void Drag()
+        {
+            if (isPlaying) meido.Stop = true;
+
+            if (CurrentDragType == DragType.RotLocalXZ)
+            {
+                Porc(IK, ikCtrlData, ikChain[jointUpper], ikChain[jointMiddle], ikChain[jointLower]);
+                InitializeRotation();
+            }
+
+            if (CurrentDragType == DragType.RotLocalY)
+            {
+                Vector3 mouseDelta = MouseDelta();
+                ikChain[jointLower].localRotation = jointRotation[jointLower];
+                ikChain[jointLower].Rotate(Vector3.up * (-mouseDelta.x / 1.5f) * inv);
+                ikChain[jointLower].Rotate(Vector3.forward * (mouseDelta.y / 1.5f) * inv);
+            }
+        }
+    }
+}

+ 771 - 0
src/MeidoPhotoStudio.Plugin/Meido/Meido.cs

@@ -0,0 +1,771 @@
+using System;
+using System.IO;
+using System.Collections;
+using System.Collections.Generic;
+using System.Reflection;
+using System.Linq;
+using System.Xml.Linq;
+using UnityEngine;
+using static TBody;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class Meido
+    {
+        private bool initialized;
+        private float[] BlendSetValueBackup;
+        private readonly FieldInfo m_eMaskMode = Utility.GetFieldInfo<TBody>("m_eMaskMode");
+        public MaskMode CurrentMaskMode => !Body.isLoadedBody ? default : (MaskMode) m_eMaskMode.GetValue(Body);
+        public DragPointGravity HairGravityControl { get; private set; }
+        public DragPointGravity SkirtGravityControl { get; private set; }
+        public bool HairGravityActive
+        {
+            get => HairGravityControl.Active;
+            set
+            {
+                if (HairGravityControl.Valid) HairGravityControl.gameObject.SetActive(value);
+            }
+        }
+        public bool SkirtGravityActive
+        {
+            get => SkirtGravityControl.Active;
+            set
+            {
+                if (SkirtGravityControl.Valid) SkirtGravityControl.gameObject.SetActive(value);
+            }
+        }
+        public static readonly string defaultFaceBlendSet = "通常";
+        public static readonly string[] faceKeys = new string[24]
+        {
+            "eyeclose", "eyeclose2", "eyeclose3", "eyebig", "eyeclose6", "eyeclose5", "hitomih",
+            "hitomis", "mayuha", "mayuw", "mayuup", "mayuv", "mayuvhalf", "moutha", "mouths",
+            "mouthc", "mouthi", "mouthup", "mouthdw", "mouthhe", "mouthuphalf", "tangout",
+            "tangup", "tangopen"
+        };
+
+        public static readonly string[] faceToggleKeys = new string[12]
+        {
+            // blush, shade, nose up, tears, drool, teeth
+            "hoho2", "shock", "nosefook", "namida", "yodare", "toothoff",
+            // cry 1, cry 2, cry 3, blush 1, blush 2, blush 3
+            "tear1", "tear2", "tear3", "hohos", "hoho", "hohol"
+        };
+        public enum Curl { Front, Back, Shift }
+        public enum Mask { All, Underwear, Nude }
+        public event EventHandler<MeidoUpdateEventArgs> UpdateMeido;
+        public int StockNo { get; }
+        public Maid Maid { get; }
+        public TBody Body => Maid.body0;
+        public MeidoDragPointManager IKManager { get; }
+        public Texture2D Portrait => Maid.GetThumIcon();
+        public bool IsEditMaid { get; set; }
+        public PoseInfo CachedPose { get; private set; } = PoseInfo.DefaultPose;
+        public string CurrentFaceBlendSet { get; private set; } = defaultFaceBlendSet;
+        public int Slot { get; private set; }
+        public bool Loading { get; private set; }
+        public string FirstName => Maid.status.firstName;
+        public string LastName => Maid.status.lastName;
+        public bool Busy => Maid.IsBusy || Loading;
+        public bool CurlingFront => Maid.IsItemChange("skirt", "めくれスカート")
+            || Maid.IsItemChange("onepiece", "めくれスカート");
+        public bool CurlingBack => Maid.IsItemChange("skirt", "めくれスカート後ろ")
+            || Maid.IsItemChange("onepiece", "めくれスカート後ろ");
+        public bool PantsuShift => Maid.IsItemChange("panz", "パンツずらし")
+            || Maid.IsItemChange("mizugi", "パンツずらし");
+        private bool freeLook;
+        public bool FreeLook
+        {
+            get => freeLook;
+            set
+            {
+                if (freeLook == value) return;
+                freeLook = value;
+                Body.trsLookTarget = freeLook ? null : GameMain.Instance.MainCamera.transform;
+                OnUpdateMeido();
+            }
+        }
+        public bool HeadToCam
+        {
+            get => Body.isLoadedBody && Body.boHeadToCam;
+            set
+            {
+                if (!Body.isLoadedBody || HeadToCam == value) return;
+                Body.HeadToCamPer = 0f;
+                Body.boHeadToCam = value;
+                if (!HeadToCam && !EyeToCam) FreeLook = false;
+                OnUpdateMeido();
+            }
+        }
+        public bool EyeToCam
+        {
+            get => Body.isLoadedBody && Body.boEyeToCam;
+            set
+            {
+                if (!Body.isLoadedBody || EyeToCam == value) return;
+                Body.boEyeToCam = value;
+                if (!HeadToCam && !EyeToCam) FreeLook = false;
+                OnUpdateMeido();
+            }
+        }
+        public bool Stop
+        {
+            get => !Body.isLoadedBody || !Maid.GetAnimation().isPlaying;
+            set
+            {
+                if (!Body.isLoadedBody || value == Stop) return;
+                if (value) Maid.GetAnimation().Stop();
+                else
+                {
+                    Body.boEyeToCam = true;
+                    Body.boHeadToCam = true;
+                    SetPose(CachedPose.Pose);
+                }
+
+                OnUpdateMeido();
+            }
+        }
+        public bool IK
+        {
+            get => IKManager.Active;
+            set
+            {
+                if (value == IKManager.Active) return;
+
+                IKManager.Active = value;
+            }
+        }
+        public bool Bone
+        {
+            get => IKManager.IsBone;
+            set
+            {
+                if (value == Bone) return;
+
+                IKManager.IsBone = value;
+                OnUpdateMeido();
+            }
+        }
+        public event EventHandler<GravityEventArgs> GravityMove;
+        public Quaternion DefaultEyeRotL { get; private set; }
+        public Quaternion DefaultEyeRotR { get; private set; }
+
+        public Meido(int stockMaidIndex)
+        {
+            StockNo = stockMaidIndex;
+            Maid = GameMain.Instance.CharacterMgr.GetStockMaid(stockMaidIndex);
+            IKManager = new MeidoDragPointManager(this);
+            IKManager.SelectMaid += (s, args) => OnUpdateMeido(args);
+        }
+
+        public void Load(int slot)
+        {
+            if (Busy) return;
+
+            Slot = slot;
+
+            FreeLook = false;
+            Maid.Visible = true;
+            Body.boHeadToCam = true;
+            Body.boEyeToCam = true;
+            Body.SetBoneHitHeightY(-1000f);
+
+            if (!Body.isLoadedBody)
+            {
+                Maid.DutPropAll();
+                Maid.AllProcPropSeqStart();
+            }
+
+            StartLoad(OnBodyLoad);
+        }
+
+        private void StartLoad(Action callback)
+        {
+            if (Loading) return;
+            GameMain.Instance.StartCoroutine(Load(callback));
+        }
+
+        private IEnumerator Load(Action callback)
+        {
+            Loading = true;
+            while (Maid.IsBusy) yield return null;
+            yield return new WaitForEndOfFrame();
+            callback();
+            Loading = false;
+        }
+
+        private void OnBodyLoad()
+        {
+            if (!initialized)
+            {
+                DefaultEyeRotL = Body.quaDefEyeL;
+                DefaultEyeRotR = Body.quaDefEyeR;
+
+                initialized = true;
+            }
+
+            if (BlendSetValueBackup == null) BackupBlendSetValues();
+
+            if (!HairGravityControl) InitializeGravityControls();
+
+            HairGravityControl.Move += OnGravityEvent;
+            SkirtGravityControl.Move += OnGravityEvent;
+            if (MeidoPhotoStudio.EditMode) AllProcPropSeqStartPatcher.SequenceStart += ReinitializeBody;
+
+            IKManager.Initialize();
+
+            IK = true;
+            Stop = false;
+            Bone = false;
+        }
+
+        private void ReinitializeBody(object sender, ProcStartEventArgs args)
+        {
+            if (Loading || !Body.isLoadedBody) return;
+
+            if (args.maid.status.guid == Maid.status.guid)
+            {
+                MPN[] gravityControlProps = new[] {
+                    MPN.skirt, MPN.onepiece, MPN.mizugi, MPN.panz, MPN.set_maidwear, MPN.set_mywear, MPN.set_underwear,
+                    MPN.hairf, MPN.hairr, MPN.hairs, MPN.hairt
+                };
+
+                // Change body
+                if (Maid.GetProp(MPN.body).boDut)
+                {
+                    IKManager.Destroy();
+                    StartLoad(reinitializeBody);
+                }
+                // Change face
+                else if (Maid.GetProp(MPN.head).boDut)
+                {
+                    SetFaceBlendSet(defaultFaceBlendSet);
+                    StartLoad(reinitializeFace);
+                }
+                // Gravity control clothing/hair change
+                else if (gravityControlProps.Any(prop => Maid.GetProp(prop).boDut))
+                {
+                    if (HairGravityControl) GameObject.Destroy(HairGravityControl.gameObject);
+                    if (SkirtGravityControl) GameObject.Destroy(SkirtGravityControl.gameObject);
+
+                    StartLoad(reinitializeGravity);
+                }
+                // Clothing/accessory changes
+                // Includes null_mpn too but any button click results in null_mpn bodut I think
+                else StartLoad(() => OnUpdateMeido());
+
+                void reinitializeBody()
+                {
+                    IKManager.Initialize();
+                    Stop = false;
+
+                    // Maid animation needs to be set again for custom parts edit
+                    GameObject uiRoot = GameObject.Find("UI Root");
+
+                    var customPartsWindow = UTY.GetChildObject(uiRoot, "Window/CustomPartsWindow")
+                        .GetComponent<SceneEditWindow.CustomPartsWindow>();
+
+                    Utility.SetFieldValue(customPartsWindow, "animation", Maid.GetAnimation());
+                }
+
+                void reinitializeFace()
+                {
+                    DefaultEyeRotL = Body.quaDefEyeL;
+                    DefaultEyeRotR = Body.quaDefEyeR;
+                    BackupBlendSetValues();
+                }
+
+                void reinitializeGravity()
+                {
+                    InitializeGravityControls();
+                    OnUpdateMeido();
+                }
+            }
+        }
+
+        public void Unload()
+        {
+            if (Body.isLoadedBody && Maid.Visible)
+            {
+                DetachAllMpnAttach();
+                Body.jbMuneL.enabled = true;
+                Body.jbMuneR.enabled = true;
+
+                Body.quaDefEyeL = DefaultEyeRotL;
+                Body.quaDefEyeR = DefaultEyeRotR;
+
+                if (HairGravityControl)
+                {
+                    HairGravityControl.Move -= OnGravityEvent;
+                    HairGravityActive = false;
+                }
+
+                if (SkirtGravityControl)
+                {
+                    SkirtGravityControl.Move -= OnGravityEvent;
+                    SkirtGravityActive = false;
+                }
+
+                ApplyGravity(Vector3.zero, skirt: false);
+                ApplyGravity(Vector3.zero, skirt: true);
+
+                SetFaceBlendSet(defaultFaceBlendSet);
+            }
+
+            AllProcPropSeqStartPatcher.SequenceStart -= ReinitializeBody;
+
+            Body.MuneYureL(1f);
+            Body.MuneYureR(1f);
+
+            Body.SetMaskMode(MaskMode.None);
+            Body.SetBoneHitHeightY(0f);
+
+            Maid.Visible = false;
+
+            IKManager.Destroy();
+        }
+
+        public void Deactivate()
+        {
+            Unload();
+
+            if (HairGravityControl) GameObject.Destroy(HairGravityControl.gameObject);
+            if (SkirtGravityControl) GameObject.Destroy(SkirtGravityControl.gameObject);
+
+            Maid.SetPos(Vector3.zero);
+            Maid.SetRot(Vector3.zero);
+            Maid.SetPosOffset(Vector3.zero);
+            Body.transform.localScale = Vector3.one;
+            Maid.ResetAll();
+            Maid.MabatakiUpdateStop = false;
+            Maid.ActiveSlotNo = -1;
+        }
+
+        public void SetPose(PoseInfo poseInfo)
+        {
+            CachedPose = poseInfo;
+            SetPose(poseInfo.Pose);
+        }
+
+        public void SetPose(string pose)
+        {
+            if (!Body.isLoadedBody) return;
+
+            if (pose.StartsWith(Constants.customPosePath))
+            {
+                string poseFilename = Path.GetFileNameWithoutExtension(pose);
+                try
+                {
+                    byte[] poseBuffer = File.ReadAllBytes(pose);
+                    string hash = Path.GetFileName(pose).GetHashCode().ToString();
+                    Body.CrossFade(hash, poseBuffer, loop: true, fade: 0f);
+                }
+                catch (Exception e) when (e is DirectoryNotFoundException || e is FileNotFoundException)
+                {
+                    Utility.LogWarning($"{poseFilename}: Could not open because {e.Message}");
+                    Constants.InitializeCustomPoses();
+                    return;
+                }
+                catch (Exception e)
+                {
+                    Utility.LogWarning($"{poseFilename}: Could not apply pose because {e.Message}");
+                    return;
+                }
+                SetMune(true, left: true);
+                SetMune(true, left: false);
+            }
+            else
+            {
+                string[] poseComponents = pose.Split(',');
+                pose = poseComponents[0] + ".anm";
+
+                Maid.CrossFade(pose, loop: true, val: 0f);
+                Maid.GetAnimation().Play();
+
+                if (poseComponents.Length > 1)
+                {
+                    Maid.GetAnimation()[pose].time = float.Parse(poseComponents[1]);
+                    Maid.GetAnimation()[pose].speed = 0f;
+                }
+                SetPoseMune();
+            }
+
+            Maid.SetAutoTwistAll(true);
+        }
+
+        public KeyValuePair<bool, bool> SetFrameBinary(byte[] poseBuffer)
+            => GetCacheBoneData().SetFrameBinary(poseBuffer);
+
+        public void CopyPose(Meido fromMeido)
+        {
+            Stop = true;
+            SetFrameBinary(fromMeido.SerializePose(frameBinary: true));
+            SetMune(fromMeido.Body.GetMuneYureL() != 0f, left: true);
+            SetMune(fromMeido.Body.GetMuneYureR() != 0f, left: false);
+        }
+
+        public void SetMune(bool enabled, bool left = false)
+        {
+            float value = enabled ? 1f : 0f;
+            if (left)
+            {
+                Body.MuneYureL(value);
+                Body.jbMuneL.enabled = enabled;
+            }
+            else
+            {
+                Body.MuneYureR(value);
+                Body.jbMuneR.enabled = enabled;
+            }
+        }
+
+        private void SetPoseMune()
+        {
+            bool momiOrPaizuri = CachedPose.Pose.Contains("_momi") || CachedPose.Pose.Contains("paizuri_");
+            SetMune(!momiOrPaizuri, left: true);
+            SetMune(!momiOrPaizuri, left: false);
+        }
+
+        public void SetHandPreset(string filename, bool right)
+        {
+            string faceFilename = Path.GetFileNameWithoutExtension(filename);
+            try
+            {
+                XDocument handDocument = XDocument.Load(filename);
+                XElement handElement = handDocument.Element("FingerData");
+
+                if (handElement?.Elements().Any(element => element?.IsEmpty ?? true) ?? true)
+                {
+                    Utility.LogWarning($"{faceFilename}: Could not apply hand preset because it is invalid.");
+                    return;
+                }
+
+                Stop = true;
+
+                bool rightData = bool.Parse(handElement.Element("RightData").Value);
+                string base64Data = handElement.Element("BinaryData").Value;
+
+                byte[] handData = Convert.FromBase64String(base64Data);
+
+                IKManager.DeserializeHand(handData, right, rightData != right);
+            }
+            catch (System.Xml.XmlException e)
+            {
+                Utility.LogWarning($"{faceFilename}: Hand preset data is malformed because {e.Message}");
+            }
+            catch (Exception e) when (e is DirectoryNotFoundException || e is FileNotFoundException)
+            {
+                Utility.LogWarning($"{faceFilename}: Could not open hand preset because {e.Message}");
+                Constants.InitializeHandPresets();
+            }
+            catch (Exception e)
+            {
+                Utility.LogWarning($"{faceFilename}: Could not parse hand preset because {e.Message}");
+            }
+        }
+
+        public byte[] SerializePose(bool frameBinary = false)
+        {
+            CacheBoneDataArray cache = GetCacheBoneData();
+            bool muneL = Body.GetMuneYureL() == 0f;
+            bool muneR = Body.GetMuneYureR() == 0f;
+            return frameBinary ? cache.GetFrameBinary(muneL, muneR) : cache.GetAnmBinary(true, true);
+        }
+
+        public Dictionary<string, float> SerializeFace()
+        {
+            Dictionary<string, float> faceData = new Dictionary<string, float>();
+            foreach (string hash in faceKeys.Concat(faceToggleKeys))
+            {
+                try
+                {
+                    float value = GetFaceBlendValue(hash);
+                    faceData.Add(hash, value);
+                }
+                catch { }
+            }
+
+            return faceData;
+        }
+
+        public void SetFaceBlendSet(string blendSet)
+        {
+            if (blendSet.StartsWith(Constants.customFacePath))
+            {
+                string blendSetFileName = Path.GetFileNameWithoutExtension(blendSet);
+                try
+                {
+                    XDocument faceDocument = XDocument.Load(blendSet, LoadOptions.SetLineInfo);
+                    XElement faceDataElement = faceDocument.Element("FaceData");
+                    if (faceDataElement?.IsEmpty ?? true)
+                    {
+                        Utility.LogWarning($"{blendSetFileName}: Could not apply face preset because it is invalid.");
+                        return;
+                    }
+
+                    HashSet<string> hashKeys = new HashSet<string>(faceKeys.Concat(faceToggleKeys));
+
+                    foreach (XElement element in faceDataElement.Elements())
+                    {
+                        System.Xml.IXmlLineInfo info = element;
+                        int line = info.HasLineInfo() ? info.LineNumber : -1;
+                        string key;
+
+                        if ((key = (string)element.Attribute("name")) == null)
+                        {
+                            Utility.LogWarning($"{blendSetFileName}: Could not read face blend key at line {line}.");
+                            continue;
+                        }
+
+                        if (!hashKeys.Contains(key))
+                        {
+                            Utility.LogWarning($"{blendSetFileName}: Invalid face blend key '{key}' at line {line}.");
+                            continue;
+                        }
+
+                        if (float.TryParse(element.Value, out float value))
+                        {
+                            try { SetFaceBlendValue(key, value); }
+                            catch { }
+                        }
+                        else Utility.LogWarning(
+                            $"{blendSetFileName}: Could not parse value '{element.Value}' of '{key}' at line {line}"
+                        );
+                    }
+                }
+                catch (System.Xml.XmlException e)
+                {
+                    Utility.LogWarning($"{blendSetFileName}: Face preset data is malformed because {e.Message}");
+                    return;
+                }
+                catch (Exception e) when (e is DirectoryNotFoundException || e is FileNotFoundException)
+                {
+                    Utility.LogWarning($"{blendSetFileName}: Could not open face preset because {e.Message}");
+                    Constants.InitializeCustomFaceBlends();
+                    return;
+                }
+                catch (Exception e)
+                {
+                    Utility.LogWarning($"{blendSetFileName}: Could not parse face preset because {e.Message}");
+                    return;
+                }
+            }
+            else
+            {
+                ApplyBackupBlendSet();
+
+                CurrentFaceBlendSet = blendSet;
+
+                BackupBlendSetValues();
+
+                Maid.FaceAnime(blendSet, 0f);
+
+                var morph = Body.Face.morph;
+
+                foreach (var faceKey in faceKeys)
+                {
+                    var hash = Utility.GP01FbFaceHash(morph, faceKey);
+                    if (!morph.Contains(hash)) continue;
+
+                    var blendIndex = (int) morph.hash[hash];
+                    var value = faceKey == "nosefook"
+                        ? Maid.boNoseFook || morph.boNoseFook ? 1f : 0f
+                        : morph.dicBlendSet[CurrentFaceBlendSet][blendIndex];
+
+                    morph.SetBlendValues(blendIndex, value);
+                }
+
+                morph.FixBlendValues();
+            }
+
+            StopBlink();
+            OnUpdateMeido();
+        }
+
+        public void SetFaceBlendValue(string faceKey, float value)
+        {
+            TMorph morph = Body.Face.morph;
+            var hash = Utility.GP01FbFaceHash(morph, faceKey);
+            if (!morph.Contains(hash)) return;
+
+            var blendIndex = (int) morph.hash[hash];
+            if (faceKey == "nosefook") Maid.boNoseFook = morph.boNoseFook = value > 0f;
+            else morph.dicBlendSet[CurrentFaceBlendSet][blendIndex] = value;
+
+            morph.SetBlendValues(blendIndex, value);
+            morph.FixBlendValues();
+        }
+
+        public float GetFaceBlendValue(string hash)
+        {
+            TMorph morph = Body.Face.morph;
+            if (hash == "nosefook") return (Maid.boNoseFook || morph.boNoseFook) ? 1f : 0f;
+            hash = Utility.GP01FbFaceHash(morph, hash);
+            return morph.dicBlendSet[CurrentFaceBlendSet][(int)morph.hash[hash]];
+        }
+
+        public void StopBlink()
+        {
+            Maid.MabatakiUpdateStop = true;
+            Body.Face.morph.EyeMabataki = 0f;
+            Utility.SetFieldValue(Maid, "MabatakiVal", 0f);
+        }
+
+        public void SetMaskMode(Mask maskMode)
+            => SetMaskMode(maskMode == Mask.Nude ? MaskMode.Nude : (MaskMode) maskMode);
+
+        public void SetMaskMode(MaskMode maskMode)
+        {
+            bool invisibleBody = !Body.GetMask(SlotID.body);
+            Body.SetMaskMode(maskMode);
+            if (invisibleBody) SetBodyMask(false);
+        }
+
+        public void SetBodyMask(bool enabled)
+        {
+            Hashtable table = Utility.GetFieldValue<TBody, Hashtable>(Body, "m_hFoceHide");
+            foreach (SlotID bodySlot in MaidDressingPane.BodySlots) table[bodySlot] = enabled;
+            Body.FixMaskFlag();
+            Body.FixVisibleFlag(false);
+        }
+
+        public void SetCurling(Curl curling, bool enabled)
+        {
+            string[] name = curling == Curl.Shift
+                ? new[] { "panz", "mizugi" }
+                : new[] { "skirt", "onepiece" };
+            if (enabled)
+            {
+                var action = curling switch
+                {
+                    Curl.Shift => "パンツずらし",
+                    Curl.Front => "めくれスカート",
+                    _ => "めくれスカート後ろ"
+                };
+                Maid.ItemChangeTemp(name[0], action);
+                Maid.ItemChangeTemp(name[1], action);
+            }
+            else
+            {
+                Maid.ResetProp(name[0]);
+                Maid.ResetProp(name[1]);
+            }
+            Maid.AllProcProp();
+            HairGravityControl.Control.OnChangeMekure();
+            SkirtGravityControl.Control.OnChangeMekure();
+        }
+
+        public void SetMpnProp(MpnAttachProp prop, bool detach)
+        {
+            if (detach) Maid.ResetProp(prop.Tag, false);
+            else Maid.SetProp(prop.Tag, prop.MenuFile, 0, true);
+            Maid.AllProcProp();
+        }
+
+        public void DetachAllMpnAttach()
+        {
+            Maid.ResetProp(MPN.kousoku_lower, false);
+            Maid.ResetProp(MPN.kousoku_upper, false);
+            Maid.AllProcProp();
+        }
+
+        public void ApplyGravity(Vector3 position, bool skirt = false)
+        {
+            DragPointGravity dragPoint = skirt ? SkirtGravityControl : HairGravityControl;
+            if (dragPoint.Valid) dragPoint.Control.transform.localPosition = position;
+        }
+
+        private void BackupBlendSetValues()
+        {
+            float[] values = Body.Face.morph.dicBlendSet[CurrentFaceBlendSet];
+            BlendSetValueBackup = new float[values.Length];
+            values.CopyTo(BlendSetValueBackup, 0);
+        }
+
+        private void ApplyBackupBlendSet()
+        {
+            BlendSetValueBackup.CopyTo(Body.Face.morph.dicBlendSet[CurrentFaceBlendSet], 0);
+            Maid.boNoseFook = false;
+        }
+
+        private CacheBoneDataArray GetCacheBoneData()
+        {
+            CacheBoneDataArray cache = Maid.gameObject.GetComponent<CacheBoneDataArray>();
+            void CreateCache() => cache.CreateCache(Body.GetBone("Bip01"));
+            if (cache == null)
+            {
+                cache = Maid.gameObject.AddComponent<CacheBoneDataArray>();
+                CreateCache();
+            }
+            if (cache.bone_data?.transform == null)
+            {
+                Utility.LogDebug("Cache bone_data is null");
+                CreateCache();
+            }
+            return cache;
+        }
+
+        private void InitializeGravityControls()
+        {
+            HairGravityControl = MakeGravityControl(skirt: false);
+            SkirtGravityControl = MakeGravityControl(skirt: true);
+        }
+
+        private DragPointGravity MakeGravityControl(bool skirt = false)
+        {
+            DragPointGravity gravityDragpoint = DragPoint.Make<DragPointGravity>(
+                PrimitiveType.Cube, Vector3.one * 0.12f
+            );
+            GravityTransformControl control = DragPointGravity.MakeGravityControl(Maid, skirt);
+            gravityDragpoint.Initialize(() => control.transform.position, () => Vector3.zero);
+            gravityDragpoint.Set(control.transform);
+
+            gravityDragpoint.gameObject.SetActive(false);
+
+            return gravityDragpoint;
+        }
+
+        private void OnUpdateMeido(MeidoUpdateEventArgs args = null)
+        {
+            UpdateMeido?.Invoke(this, args ?? MeidoUpdateEventArgs.Empty);
+        }
+
+        private void OnGravityEvent(object sender, EventArgs args) => OnGravityChange((DragPointGravity)sender);
+
+        private void OnGravityChange(DragPointGravity dragPoint)
+        {
+            GravityEventArgs args = new GravityEventArgs(
+                dragPoint == SkirtGravityControl, dragPoint.MyObject.transform.localPosition
+            );
+            GravityMove?.Invoke(this, args);
+        }
+    }
+
+    public class GravityEventArgs : EventArgs
+    {
+        public Vector3 LocalPosition { get; }
+        public bool IsSkirt { get; }
+
+        public GravityEventArgs(bool isSkirt, Vector3 localPosition)
+        {
+            LocalPosition = localPosition;
+            IsSkirt = isSkirt;
+        }
+    }
+
+    public readonly struct PoseInfo
+    {
+        public string PoseGroup { get; }
+        public string Pose { get; }
+        public bool CustomPose { get; }
+        private static readonly PoseInfo defaultPose =
+            new PoseInfo(Constants.PoseGroupList[0], Constants.PoseDict[Constants.PoseGroupList[0]][0]);
+        public static ref readonly PoseInfo DefaultPose => ref defaultPose;
+
+        public PoseInfo(string poseGroup, string pose, bool customPose = false)
+        {
+            PoseGroup = poseGroup;
+            Pose = pose;
+            CustomPose = customPose;
+        }
+    }
+}

+ 701 - 0
src/MeidoPhotoStudio.Plugin/Meido/MeidoDragPointManager.cs

@@ -0,0 +1,701 @@
+using System;
+using System.IO;
+using System.Collections.Generic;
+using System.Linq;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public enum AttachPoint
+    {
+        None, Head, Neck, UpperArmL, UpperArmR, ForearmL, ForearmR, MuneL, MuneR, HandL, HandR,
+        Pelvis, ThighL, ThighR, CalfL, CalfR, FootL, FootR, Spine1a, Spine1, Spine0a, Spine0
+    }
+
+    public class MeidoDragPointManager
+    {
+        private enum Bone
+        {
+            Head, HeadNub, ClavicleL, ClavicleR,
+            UpperArmL, UpperArmR, ForearmL, ForearmR,
+            HandL, HandR, /*IKHandL, IKHandR,*/
+            MuneL, MuneSubL, MuneR, MuneSubR,
+            Neck, Spine, Spine0a, Spine1, Spine1a, ThighL, ThighR,
+            Pelvis, Hip,
+            CalfL, CalfR, FootL, FootR,
+            // Dragpoint specific
+            Cube, Body, Torso,
+            // Fingers
+            Finger0L, Finger01L, Finger02L, Finger0NubL,
+            Finger1L, Finger11L, Finger12L, Finger1NubL,
+            Finger2L, Finger21L, Finger22L, Finger2NubL,
+            Finger3L, Finger31L, Finger32L, Finger3NubL,
+            Finger4L, Finger41L, Finger42L, Finger4NubL,
+            Finger0R, Finger01R, Finger02R, Finger0NubR,
+            Finger1R, Finger11R, Finger12R, Finger1NubR,
+            Finger2R, Finger21R, Finger22R, Finger2NubR,
+            Finger3R, Finger31R, Finger32R, Finger3NubR,
+            Finger4R, Finger41R, Finger42R, Finger4NubR,
+            // Toes
+            Toe0L, Toe01L, Toe0NubL,
+            Toe1L, Toe11L, Toe1NubL,
+            Toe2L, Toe21L, Toe2NubL,
+            Toe0R, Toe01R, Toe0NubR,
+            Toe1R, Toe11R, Toe1NubR,
+            Toe2R, Toe21R, Toe2NubR
+        }
+        private static readonly Dictionary<AttachPoint, Bone> PointToBone = new Dictionary<AttachPoint, Bone>()
+        {
+            [AttachPoint.Head] = Bone.Head,
+            [AttachPoint.Neck] = Bone.HeadNub,
+            [AttachPoint.UpperArmL] = Bone.UpperArmL,
+            [AttachPoint.UpperArmR] = Bone.UpperArmR,
+            [AttachPoint.ForearmL] = Bone.ForearmL,
+            [AttachPoint.ForearmR] = Bone.ForearmR,
+            [AttachPoint.MuneL] = Bone.MuneL,
+            [AttachPoint.MuneR] = Bone.MuneR,
+            [AttachPoint.HandL] = Bone.HandL,
+            [AttachPoint.HandR] = Bone.HandR,
+            [AttachPoint.Pelvis] = Bone.Pelvis,
+            [AttachPoint.ThighL] = Bone.ThighL,
+            [AttachPoint.ThighR] = Bone.ThighR,
+            [AttachPoint.CalfL] = Bone.CalfL,
+            [AttachPoint.CalfR] = Bone.CalfR,
+            [AttachPoint.FootL] = Bone.FootL,
+            [AttachPoint.FootR] = Bone.FootR,
+            [AttachPoint.Spine1a] = Bone.Spine1a,
+            [AttachPoint.Spine1] = Bone.Spine1,
+            [AttachPoint.Spine0a] = Bone.Spine0a,
+            [AttachPoint.Spine0] = Bone.Spine
+        };
+        private static readonly Bone[] SpineBones =
+        {
+            Bone.Neck, Bone.Spine, Bone.Spine0a, Bone.Spine1, Bone.Spine1a, Bone.Hip, Bone.ThighL, Bone.ThighR
+        };
+        private static bool cubeActive;
+        public static bool CubeActive
+        {
+            get => cubeActive;
+            set
+            {
+                if (value != cubeActive)
+                {
+                    cubeActive = value;
+                    CubeActiveChange?.Invoke(null, EventArgs.Empty);
+                }
+            }
+        }
+        private static bool cubeSmall;
+        public static bool CubeSmall
+        {
+            get => cubeSmall;
+            set
+            {
+                if (value != cubeSmall)
+                {
+                    cubeSmall = value;
+                    CubeSmallChange?.Invoke(null, EventArgs.Empty);
+                }
+            }
+        }
+        private static EventHandler CubeActiveChange;
+        private static EventHandler CubeSmallChange;
+        private readonly Meido meido;
+        private readonly Dictionary<Bone, DragPointMeido> DragPoints = new Dictionary<Bone, DragPointMeido>();
+        private Dictionary<Bone, Transform> BoneTransform = new Dictionary<Bone, Transform>();
+        private DragPointBody dragBody;
+        private DragPointBody dragCube;
+        private bool initialized;
+        public event EventHandler<MeidoUpdateEventArgs> SelectMaid;
+        private bool isBone;
+        public bool IsBone
+        {
+            get => isBone;
+            set
+            {
+                if (!initialized) return;
+                if (isBone != value)
+                {
+                    isBone = value;
+                    foreach (DragPointMeido dragPoint in DragPoints.Values) dragPoint.IsBone = isBone;
+                    foreach (Bone bone in SpineBones) DragPoints[bone].gameObject.SetActive(isBone);
+                }
+            }
+        }
+        private bool active = true;
+        public bool Active
+        {
+            get => active;
+            set
+            {
+                if (!initialized) return;
+                if (active != value)
+                {
+                    active = value;
+                    foreach (DragPointMeido dragPoint in DragPoints.Values) dragPoint.gameObject.SetActive(active);
+                    foreach (Bone bone in SpineBones) DragPoints[bone].gameObject.SetActive(active && IsBone);
+                    DragPointHead head = (DragPointHead)DragPoints[Bone.Head];
+                    head.gameObject.SetActive(true);
+                    head.IsIK = !active;
+                    dragBody.IsIK = !active;
+                }
+            }
+        }
+
+        public MeidoDragPointManager(Meido meido) => this.meido = meido;
+
+        public void Deserialize(BinaryReader binaryReader)
+        {
+            Bone[] bones = {
+                Bone.Hip, Bone.Pelvis, Bone.Spine, Bone.Spine0a, Bone.Spine1, Bone.Spine1a, Bone.Neck,
+                Bone.ClavicleL, Bone.ClavicleR, Bone.UpperArmL, Bone.UpperArmR, Bone.ForearmL, Bone.ForearmR,
+                Bone.ThighL, Bone.ThighR, Bone.CalfL, Bone.CalfR, Bone.MuneL, Bone.MuneR, Bone.MuneSubL, Bone.MuneSubR,
+                Bone.HandL, Bone.HandR, Bone.FootL, Bone.FootR
+            };
+            int localRotationIndex = Array.IndexOf(bones, Bone.CalfR);
+            for (Bone bone = Bone.Finger0L; bone <= Bone.Toe2NubR; ++bone)
+            {
+                BoneTransform[bone].localRotation = binaryReader.ReadQuaternion();
+            }
+            for (int i = 0; i < bones.Length; i++)
+            {
+                Bone bone = bones[i];
+                Quaternion rotation = binaryReader.ReadQuaternion();
+                if (i > localRotationIndex) BoneTransform[bone].localRotation = rotation;
+                else BoneTransform[bone].rotation = rotation;
+            }
+            // WHY????
+            GameMain.Instance.StartCoroutine(ApplyHipPosition(binaryReader.ReadVector3()));
+        }
+
+        /*
+            Somebody smarter than me please help me find a way to do this better T_T
+            inb4 for loop.
+         */
+        private System.Collections.IEnumerator ApplyHipPosition(Vector3 hipPosition)
+        {
+            BoneTransform[Bone.Hip].position = hipPosition;
+            yield return new WaitForEndOfFrame();
+            BoneTransform[Bone.Hip].position = hipPosition;
+            yield return new WaitForEndOfFrame();
+            BoneTransform[Bone.Hip].position = hipPosition;
+        }
+
+        public Transform GetAttachPointTransform(AttachPoint point)
+            => point == AttachPoint.None ? null : BoneTransform[PointToBone[point]];
+
+        public void Flip()
+        {
+            meido.Stop = true;
+            Bone[] single = new[] { Bone.Pelvis, Bone.Spine, Bone.Spine0a, Bone.Spine1, Bone.Spine1a, Bone.Neck };
+            Bone[] pair = new[] {
+                Bone.ClavicleL, Bone.ClavicleR, Bone.UpperArmL, Bone.UpperArmR, Bone.ForearmL, Bone.ForearmR,
+                Bone.ThighL, Bone.ThighR, Bone.CalfL, Bone.CalfR, Bone.HandL, Bone.HandR, Bone.FootL, Bone.FootR
+            };
+
+            List<Vector3> singleRotations = single.Select(bone => BoneTransform[bone].eulerAngles).ToList();
+            List<Vector3> pairRotations = pair.Select(bone => BoneTransform[bone].eulerAngles).ToList();
+
+            Transform hip = BoneTransform[Bone.Hip];
+            Vector3 vecHip = hip.eulerAngles;
+
+            Transform hipL = meido.Maid.body0.GetBone("Hip_L");
+            Vector3 vecHipL = hipL.eulerAngles;
+
+            Transform hipR = meido.Maid.body0.GetBone("Hip_R");
+            Vector3 vecHipR = hipR.eulerAngles;
+
+            hip.rotation = Quaternion.Euler(
+                360f - (vecHip.x + 270f) - 270f, 360f - (vecHip.y + 90f) - 90f, 360f - vecHip.z
+            );
+
+            hipL.rotation = FlipRotation(vecHipR);
+            hipR.rotation = FlipRotation(vecHipL);
+
+            for (int i = 0; i < single.Length; i++)
+            {
+                Bone bone = single[i];
+                BoneTransform[bone].rotation = FlipRotation(singleRotations[i]);
+            }
+
+            for (int i = 0; i < pair.Length; i += 2)
+            {
+                Bone boneA = pair[i];
+                Bone boneB = pair[i + 1];
+                BoneTransform[boneA].rotation = FlipRotation(pairRotations[i + 1]);
+                BoneTransform[boneB].rotation = FlipRotation(pairRotations[i]);
+            }
+
+            byte[] leftHand = SerializeHand(right: false);
+            byte[] rightHand = SerializeHand(right: true);
+            DeserializeHand(leftHand, right: true, true);
+            DeserializeHand(rightHand, right: false, true);
+            leftHand = SerializeFoot(right: false);
+            rightHand = SerializeFoot(right: true);
+            DeserializeFoot(leftHand, right: true, true);
+            DeserializeFoot(rightHand, right: false, true);
+        }
+
+        private Quaternion FlipRotation(Vector3 rotation)
+        {
+            return Quaternion.Euler(360f - rotation.x, 360f - (rotation.y + 90f) - 90f, rotation.z);
+        }
+
+        public byte[] SerializeHand(bool right)
+        {
+            Bone start = right ? Bone.Finger0R : Bone.Finger0L;
+            Bone end = right ? Bone.Finger4R : Bone.Finger4L;
+            return SerializeFinger(start, end);
+        }
+
+        public void DeserializeHand(byte[] handBinary, bool right, bool mirroring = false)
+        {
+            Bone start = right ? Bone.Finger0R : Bone.Finger0L;
+            Bone end = right ? Bone.Finger4R : Bone.Finger4L;
+            DeserializeFinger(start, end, handBinary, mirroring);
+        }
+
+        public byte[] SerializeFoot(bool right)
+        {
+            Bone start = right ? Bone.Toe0R : Bone.Toe0L;
+            Bone end = right ? Bone.Toe2R : Bone.Toe2L;
+            return SerializeFinger(start, end);
+        }
+
+        public void DeserializeFoot(byte[] footBinary, bool right, bool mirroring = false)
+        {
+            Bone start = right ? Bone.Toe0R : Bone.Toe0L;
+            Bone end = right ? Bone.Toe2R : Bone.Toe2L;
+            DeserializeFinger(start, end, footBinary, mirroring);
+        }
+
+        private byte[] SerializeFinger(Bone start, Bone end)
+        {
+            int joints = BoneTransform[start].name.Split(' ')[2].StartsWith("Finger") ? 4 : 3;
+
+            byte[] buf;
+
+            using (MemoryStream memoryStream = new MemoryStream())
+            using (BinaryWriter binaryWriter = new BinaryWriter(memoryStream))
+            {
+                for (Bone bone = start; bone <= end; bone += joints)
+                {
+                    for (int i = 0; i < joints - 1; i++)
+                    {
+                        binaryWriter.WriteQuaternion(BoneTransform[bone + i].localRotation);
+                    }
+                }
+                buf = memoryStream.ToArray();
+            }
+
+            return buf;
+        }
+
+        private void DeserializeFinger(Bone start, Bone end, byte[] fingerBinary, bool mirroring = false)
+        {
+            int joints = BoneTransform[start].name.Split(' ')[2].StartsWith("Finger") ? 4 : 3;
+
+            int mirror = mirroring ? -1 : 1;
+
+            using MemoryStream memoryStream = new MemoryStream(fingerBinary);
+            using BinaryReader binaryReader = new BinaryReader(memoryStream);
+
+            for (Bone bone = start; bone <= end; bone += joints)
+            {
+                for (int i = 0; i < joints - 1; i++)
+                {
+                    BoneTransform[bone + i].localRotation = new Quaternion
+                    (
+                        binaryReader.ReadSingle() * mirror,
+                        binaryReader.ReadSingle() * mirror,
+                        binaryReader.ReadSingle(),
+                        binaryReader.ReadSingle()
+                    );
+                }
+            }
+        }
+
+        public void Destroy()
+        {
+            foreach (DragPointMeido dragPoint in DragPoints.Values)
+            {
+                if (dragPoint != null)
+                {
+                    GameObject.Destroy(dragPoint.gameObject);
+                }
+            }
+            if (dragCube != null) GameObject.Destroy(dragCube.gameObject);
+            if (dragBody != null) GameObject.Destroy(dragBody.gameObject);
+            BoneTransform.Clear();
+            DragPoints.Clear();
+            CubeActiveChange -= OnCubeActive;
+            CubeSmallChange -= OnCubeSmall;
+            initialized = false;
+        }
+
+        public void Initialize()
+        {
+            if (initialized) return;
+            initialized = true;
+            CubeActiveChange += OnCubeActive;
+            CubeSmallChange += OnCubeSmall;
+            InitializeBones();
+            InitializeDragPoints();
+            SetDragPointScale(meido.Maid.transform.localScale.x);
+        }
+
+        private void InitializeDragPoints()
+        {
+            dragCube = DragPoint.Make<DragPointBody>(PrimitiveType.Cube, Vector3.one * 0.12f);
+            dragCube.Initialize(() => meido.Maid.transform.position, () => Vector3.zero);
+            dragCube.Set(meido.Maid.transform);
+
+            dragCube.IsCube = true;
+            dragCube.ConstantScale = true;
+            dragCube.Select += OnSelectBody;
+            dragCube.EndScale += OnSetDragPointScale;
+            dragCube.gameObject.SetActive(CubeActive);
+
+            dragBody = DragPoint.Make<DragPointBody>(PrimitiveType.Capsule, new Vector3(0.2f, 0.3f, 0.24f));
+            dragBody.Initialize(
+                () => new Vector3(
+                    (BoneTransform[Bone.Hip].position.x + BoneTransform[Bone.Spine0a].position.x) / 2f,
+                    (BoneTransform[Bone.Spine1].position.y + BoneTransform[Bone.Spine0a].position.y) / 2f,
+                    (BoneTransform[Bone.Spine0a].position.z + BoneTransform[Bone.Hip].position.z) / 2f
+                ),
+                () => new Vector3(
+                    BoneTransform[Bone.Spine0a].eulerAngles.x,
+                    BoneTransform[Bone.Spine0a].eulerAngles.y,
+                    BoneTransform[Bone.Spine0a].eulerAngles.z + 90f
+                )
+            );
+            dragBody.Set(meido.Maid.transform);
+            dragBody.Select += OnSelectBody;
+            dragBody.EndScale += OnSetDragPointScale;
+
+            // Neck Dragpoint
+            DragPointHead dragNeck = DragPoint.Make<DragPointHead>(
+                PrimitiveType.Sphere, new Vector3(0.2f, 0.24f, 0.2f)
+            );
+            dragNeck.Initialize(meido,
+                () => new Vector3(
+                    BoneTransform[Bone.Head].position.x,
+                    ((BoneTransform[Bone.Head].position.y * 1.2f) + (BoneTransform[Bone.HeadNub].position.y * 0.8f)) / 2f,
+                    BoneTransform[Bone.Head].position.z
+                ),
+                () => new Vector3(
+                    BoneTransform[Bone.Head].eulerAngles.x,
+                    BoneTransform[Bone.Head].eulerAngles.y,
+                    BoneTransform[Bone.Head].eulerAngles.z + 90f
+                )
+            );
+            dragNeck.Set(BoneTransform[Bone.Neck]);
+            dragNeck.Select += OnSelectFace;
+
+            DragPoints[Bone.Head] = dragNeck;
+
+            // Head Dragpoint
+            DragPointSpine dragHead = DragPoint.Make<DragPointSpine>(PrimitiveType.Sphere, Vector3.one * 0.045f);
+            dragHead.Initialize(meido, () => BoneTransform[Bone.Head].position, () => Vector3.zero);
+            dragHead.Set(BoneTransform[Bone.Head]);
+            dragHead.AddGizmo();
+
+            DragPoints[Bone.HeadNub] = dragHead;
+
+            // Torso Dragpoint
+            Transform spineTrans1 = BoneTransform[Bone.Spine1];
+            Transform spineTrans2 = BoneTransform[Bone.Spine1a];
+
+            DragPointTorso dragTorso = DragPoint.Make<DragPointTorso>(
+                PrimitiveType.Capsule, new Vector3(0.2f, 0.19f, 0.24f)
+            );
+            dragTorso.Initialize(meido,
+                () => new Vector3(
+                    spineTrans1.position.x,
+                    spineTrans2.position.y,
+                    spineTrans1.position.z - 0.05f
+                ),
+                () => new Vector3(
+                    spineTrans1.eulerAngles.x,
+                    spineTrans1.eulerAngles.y,
+                    spineTrans1.eulerAngles.z + 90f
+                )
+            );
+            dragTorso.Set(BoneTransform[Bone.Spine1a]);
+
+            DragPoints[Bone.Torso] = dragTorso;
+
+            // Pelvis Dragpoint
+            Transform pelvisTrans = BoneTransform[Bone.Pelvis];
+            Transform spineTrans = BoneTransform[Bone.Spine];
+
+            DragPointPelvis dragPelvis = DragPoint.Make<DragPointPelvis>(
+                PrimitiveType.Capsule, new Vector3(0.2f, 0.15f, 0.24f)
+            );
+            dragPelvis.Initialize(meido,
+
+                () => new Vector3(
+                    pelvisTrans.position.x,
+                    (pelvisTrans.position.y + spineTrans.position.y) / 2f,
+                    pelvisTrans.position.z
+                ),
+                () => new Vector3(
+                    pelvisTrans.eulerAngles.x + 90f,
+                    pelvisTrans.eulerAngles.y + 90f,
+                    pelvisTrans.eulerAngles.z
+                )
+            );
+            dragPelvis.Set(BoneTransform[Bone.Pelvis]);
+
+            DragPoints[Bone.Pelvis] = dragPelvis;
+
+            InitializeMuneDragPoint(left: true);
+            InitializeMuneDragPoint(left: false);
+
+            DragPointLimb[] armDragPointL = MakeIKChain(BoneTransform[Bone.HandL]);
+            DragPoints[Bone.UpperArmL] = armDragPointL[0];
+            DragPoints[Bone.ForearmL] = armDragPointL[1];
+            DragPoints[Bone.HandL] = armDragPointL[2];
+
+            DragPointLimb[] armDragPointR = MakeIKChain(BoneTransform[Bone.HandR]);
+            DragPoints[Bone.UpperArmR] = armDragPointR[0];
+            DragPoints[Bone.ForearmR] = armDragPointR[1];
+            DragPoints[Bone.HandR] = armDragPointR[2];
+
+            DragPointLimb[] legDragPointL = MakeIKChain(BoneTransform[Bone.FootL]);
+            DragPoints[Bone.CalfL] = legDragPointL[0];
+            DragPoints[Bone.FootL] = legDragPointL[1];
+
+            DragPointLimb[] legDragPointR = MakeIKChain(BoneTransform[Bone.FootR]);
+            DragPoints[Bone.CalfR] = legDragPointR[0];
+            DragPoints[Bone.FootR] = legDragPointR[1];
+
+            InitializeSpineDragPoint(SpineBones);
+
+            InitializeFingerDragPoint(Bone.Finger0L, Bone.Finger4R);
+            InitializeFingerDragPoint(Bone.Toe0L, Bone.Toe2R);
+        }
+
+        private void InitializeMuneDragPoint(bool left)
+        {
+            Bone mune = left ? Bone.MuneL : Bone.MuneR;
+            Bone sub = left ? Bone.MuneSubL : Bone.MuneSubR;
+            DragPointMune muneDragPoint = DragPoint.Make<DragPointMune>(PrimitiveType.Sphere, Vector3.one * 0.12f);
+            muneDragPoint.Initialize(meido,
+                () => (BoneTransform[mune].position + BoneTransform[sub].position) / 2f,
+                () => Vector3.zero
+            );
+            muneDragPoint.Set(BoneTransform[sub]);
+            DragPoints[mune] = muneDragPoint;
+        }
+
+        private DragPointLimb[] MakeIKChain(Transform lower)
+        {
+            Vector3 limbDragPointSize = Vector3.one * 0.12f;
+            // Ignore Thigh transform when making a leg IK chain
+            bool isLeg = lower.name.EndsWith("Foot");
+            DragPointLimb[] dragPoints = new DragPointLimb[isLeg ? 2 : 3];
+            for (int i = dragPoints.Length - 1; i >= 0; i--)
+            {
+                Transform joint = lower;
+                dragPoints[i] = DragPoint.Make<DragPointLimb>(PrimitiveType.Sphere, limbDragPointSize);
+                dragPoints[i].Initialize(meido, () => joint.position, () => Vector3.zero);
+                dragPoints[i].Set(joint);
+                dragPoints[i].AddGizmo();
+                lower = lower.parent;
+            }
+            return dragPoints;
+        }
+
+        private void InitializeFingerDragPoint(Bone start, Bone end)
+        {
+            Vector3 fingerDragPointSize = Vector3.one * 0.01f;
+            int joints = BoneTransform[start].name.Split(' ')[2].StartsWith("Finger") ? 4 : 3;
+            for (Bone bone = start; bone <= end; bone += joints)
+            {
+                for (int i = 1; i < joints; i++)
+                {
+                    Transform trans = BoneTransform[bone + i];
+                    DragPointFinger chain = DragPoint.Make<DragPointFinger>(PrimitiveType.Sphere, fingerDragPointSize);
+                    chain.Initialize(meido, () => trans.position, () => Vector3.zero);
+                    chain.Set(trans);
+                    DragPoints[bone + i] = chain;
+                }
+            }
+        }
+
+        private void InitializeSpineDragPoint(params Bone[] bones)
+        {
+            Vector3 spineDragPointSize = DragPointMeido.boneScale;
+            foreach (Bone bone in bones)
+            {
+                Transform spine = BoneTransform[bone];
+                PrimitiveType primitive = bone == Bone.Hip ? PrimitiveType.Cube : PrimitiveType.Sphere;
+                DragPointSpine dragPoint = DragPoint.Make<DragPointSpine>(primitive, spineDragPointSize);
+                dragPoint.Initialize(meido,
+                    () => spine.position,
+                    () => Vector3.zero
+                );
+                dragPoint.Set(spine);
+                dragPoint.AddGizmo();
+                DragPoints[bone] = dragPoint;
+                DragPoints[bone].gameObject.SetActive(false);
+            }
+        }
+
+        private void OnCubeActive(object sender, EventArgs args)
+        {
+            dragCube.gameObject.SetActive(CubeActive);
+        }
+
+        private void OnCubeSmall(object sender, EventArgs args)
+        {
+            dragCube.DragPointScale = CubeSmall ? DragPointGeneral.smallCube : 1f;
+        }
+
+        private void OnSetDragPointScale(object sender, EventArgs args)
+        {
+            SetDragPointScale(meido.Maid.transform.localScale.x);
+        }
+
+        private void OnSelectBody(object sender, EventArgs args)
+        {
+            SelectMaid?.Invoke(this, new MeidoUpdateEventArgs(meido.Slot, fromMaid: true, isBody: true));
+        }
+
+        private void OnSelectFace(object sender, EventArgs args)
+        {
+            SelectMaid?.Invoke(this, new MeidoUpdateEventArgs(meido.Slot, fromMaid: true, isBody: false));
+        }
+
+        public void SetDragPointScale(float scale)
+        {
+            foreach (DragPointMeido dragPoint in DragPoints.Values) dragPoint.DragPointScale = scale;
+            dragBody.DragPointScale = scale;
+        }
+
+        private void InitializeBones()
+        {
+            // TODO: Move to external file somehow
+            Transform transform = meido.Body.m_Bones.transform;
+            BoneTransform = new Dictionary<Bone, Transform>()
+            {
+                [Bone.Head] = CMT.SearchObjName(transform, "Bip01 Head"),
+                [Bone.Neck] = CMT.SearchObjName(transform, "Bip01 Neck"),
+                [Bone.HeadNub] = CMT.SearchObjName(transform, "Bip01 HeadNub"),
+                /*[Bone.IKHandL] = CMT.SearchObjName(transform, "_IK_handL"),
+                [Bone.IKHandR] = CMT.SearchObjName(transform, "_IK_handR"),*/
+                [Bone.MuneL] = CMT.SearchObjName(transform, "Mune_L"),
+                [Bone.MuneSubL] = CMT.SearchObjName(transform, "Mune_L_sub"),
+                [Bone.MuneR] = CMT.SearchObjName(transform, "Mune_R"),
+                [Bone.MuneSubR] = CMT.SearchObjName(transform, "Mune_R_sub"),
+                [Bone.Pelvis] = CMT.SearchObjName(transform, "Bip01 Pelvis"),
+                [Bone.Hip] = CMT.SearchObjName(transform, "Bip01"),
+                [Bone.Spine] = CMT.SearchObjName(transform, "Bip01 Spine"),
+                [Bone.Spine0a] = CMT.SearchObjName(transform, "Bip01 Spine0a"),
+                [Bone.Spine1] = CMT.SearchObjName(transform, "Bip01 Spine1"),
+                [Bone.Spine1a] = CMT.SearchObjName(transform, "Bip01 Spine1a"),
+                [Bone.ClavicleL] = CMT.SearchObjName(transform, "Bip01 L Clavicle"),
+                [Bone.ClavicleR] = CMT.SearchObjName(transform, "Bip01 R Clavicle"),
+                [Bone.UpperArmL] = CMT.SearchObjName(transform, "Bip01 L UpperArm"),
+                [Bone.ForearmL] = CMT.SearchObjName(transform, "Bip01 L Forearm"),
+                [Bone.HandL] = CMT.SearchObjName(transform, "Bip01 L Hand"),
+                [Bone.UpperArmR] = CMT.SearchObjName(transform, "Bip01 R UpperArm"),
+                [Bone.ForearmR] = CMT.SearchObjName(transform, "Bip01 R Forearm"),
+                [Bone.HandR] = CMT.SearchObjName(transform, "Bip01 R Hand"),
+                [Bone.ThighL] = CMT.SearchObjName(transform, "Bip01 L Thigh"),
+                [Bone.CalfL] = CMT.SearchObjName(transform, "Bip01 L Calf"),
+                [Bone.FootL] = CMT.SearchObjName(transform, "Bip01 L Foot"),
+                [Bone.ThighR] = CMT.SearchObjName(transform, "Bip01 R Thigh"),
+                [Bone.CalfR] = CMT.SearchObjName(transform, "Bip01 R Calf"),
+                [Bone.FootR] = CMT.SearchObjName(transform, "Bip01 R Foot"),
+                // fingers
+                [Bone.Finger0L] = CMT.SearchObjName(transform, "Bip01 L Finger0"),
+                [Bone.Finger01L] = CMT.SearchObjName(transform, "Bip01 L Finger01"),
+                [Bone.Finger02L] = CMT.SearchObjName(transform, "Bip01 L Finger02"),
+                [Bone.Finger0NubL] = CMT.SearchObjName(transform, "Bip01 L Finger0Nub"),
+                [Bone.Finger1L] = CMT.SearchObjName(transform, "Bip01 L Finger1"),
+                [Bone.Finger11L] = CMT.SearchObjName(transform, "Bip01 L Finger11"),
+                [Bone.Finger12L] = CMT.SearchObjName(transform, "Bip01 L Finger12"),
+                [Bone.Finger1NubL] = CMT.SearchObjName(transform, "Bip01 L Finger1Nub"),
+                [Bone.Finger2L] = CMT.SearchObjName(transform, "Bip01 L Finger2"),
+                [Bone.Finger21L] = CMT.SearchObjName(transform, "Bip01 L Finger21"),
+                [Bone.Finger22L] = CMT.SearchObjName(transform, "Bip01 L Finger22"),
+                [Bone.Finger2NubL] = CMT.SearchObjName(transform, "Bip01 L Finger2Nub"),
+                [Bone.Finger3L] = CMT.SearchObjName(transform, "Bip01 L Finger3"),
+                [Bone.Finger31L] = CMT.SearchObjName(transform, "Bip01 L Finger31"),
+                [Bone.Finger32L] = CMT.SearchObjName(transform, "Bip01 L Finger32"),
+                [Bone.Finger3NubL] = CMT.SearchObjName(transform, "Bip01 L Finger3Nub"),
+                [Bone.Finger4L] = CMT.SearchObjName(transform, "Bip01 L Finger4"),
+                [Bone.Finger41L] = CMT.SearchObjName(transform, "Bip01 L Finger41"),
+                [Bone.Finger42L] = CMT.SearchObjName(transform, "Bip01 L Finger42"),
+                [Bone.Finger4NubL] = CMT.SearchObjName(transform, "Bip01 L Finger4Nub"),
+                [Bone.Finger0R] = CMT.SearchObjName(transform, "Bip01 R Finger0"),
+                [Bone.Finger01R] = CMT.SearchObjName(transform, "Bip01 R Finger01"),
+                [Bone.Finger02R] = CMT.SearchObjName(transform, "Bip01 R Finger02"),
+                [Bone.Finger0NubR] = CMT.SearchObjName(transform, "Bip01 R Finger0Nub"),
+                [Bone.Finger1R] = CMT.SearchObjName(transform, "Bip01 R Finger1"),
+                [Bone.Finger11R] = CMT.SearchObjName(transform, "Bip01 R Finger11"),
+                [Bone.Finger12R] = CMT.SearchObjName(transform, "Bip01 R Finger12"),
+                [Bone.Finger1NubR] = CMT.SearchObjName(transform, "Bip01 R Finger1Nub"),
+                [Bone.Finger2R] = CMT.SearchObjName(transform, "Bip01 R Finger2"),
+                [Bone.Finger21R] = CMT.SearchObjName(transform, "Bip01 R Finger21"),
+                [Bone.Finger22R] = CMT.SearchObjName(transform, "Bip01 R Finger22"),
+                [Bone.Finger2NubR] = CMT.SearchObjName(transform, "Bip01 R Finger2Nub"),
+                [Bone.Finger3R] = CMT.SearchObjName(transform, "Bip01 R Finger3"),
+                [Bone.Finger31R] = CMT.SearchObjName(transform, "Bip01 R Finger31"),
+                [Bone.Finger32R] = CMT.SearchObjName(transform, "Bip01 R Finger32"),
+                [Bone.Finger3NubR] = CMT.SearchObjName(transform, "Bip01 R Finger3Nub"),
+                [Bone.Finger4R] = CMT.SearchObjName(transform, "Bip01 R Finger4"),
+                [Bone.Finger41R] = CMT.SearchObjName(transform, "Bip01 R Finger41"),
+                [Bone.Finger42R] = CMT.SearchObjName(transform, "Bip01 R Finger42"),
+                [Bone.Finger4NubR] = CMT.SearchObjName(transform, "Bip01 R Finger4Nub"),
+                // Toes
+                [Bone.Toe0L] = CMT.SearchObjName(transform, "Bip01 L Toe0"),
+                [Bone.Toe01L] = CMT.SearchObjName(transform, "Bip01 L Toe01"),
+                [Bone.Toe0NubL] = CMT.SearchObjName(transform, "Bip01 L Toe0Nub"),
+                [Bone.Toe1L] = CMT.SearchObjName(transform, "Bip01 L Toe1"),
+                [Bone.Toe11L] = CMT.SearchObjName(transform, "Bip01 L Toe11"),
+                [Bone.Toe1NubL] = CMT.SearchObjName(transform, "Bip01 L Toe1Nub"),
+                [Bone.Toe2L] = CMT.SearchObjName(transform, "Bip01 L Toe2"),
+                [Bone.Toe21L] = CMT.SearchObjName(transform, "Bip01 L Toe21"),
+                [Bone.Toe2NubL] = CMT.SearchObjName(transform, "Bip01 L Toe2Nub"),
+                [Bone.Toe0R] = CMT.SearchObjName(transform, "Bip01 R Toe0"),
+                [Bone.Toe01R] = CMT.SearchObjName(transform, "Bip01 R Toe01"),
+                [Bone.Toe0NubR] = CMT.SearchObjName(transform, "Bip01 R Toe0Nub"),
+                [Bone.Toe1R] = CMT.SearchObjName(transform, "Bip01 R Toe1"),
+                [Bone.Toe11R] = CMT.SearchObjName(transform, "Bip01 R Toe11"),
+                [Bone.Toe1NubR] = CMT.SearchObjName(transform, "Bip01 R Toe1Nub"),
+                [Bone.Toe2R] = CMT.SearchObjName(transform, "Bip01 R Toe2"),
+                [Bone.Toe21R] = CMT.SearchObjName(transform, "Bip01 R Toe21"),
+                [Bone.Toe2NubR] = CMT.SearchObjName(transform, "Bip01 R Toe2Nub")
+            };
+        }
+    }
+
+    public readonly struct AttachPointInfo
+    {
+        private readonly AttachPoint attachPoint;
+        public AttachPoint AttachPoint => attachPoint;
+        public string MaidGuid { get; }
+        
+        private readonly int maidIndex;
+        public int MaidIndex => maidIndex;
+        private static readonly AttachPointInfo empty = new(AttachPoint.None, string.Empty, -1);
+        public static ref readonly AttachPointInfo Empty => ref empty;
+
+        public AttachPointInfo(AttachPoint attachPoint, Meido meido)
+        {
+            this.attachPoint = attachPoint;
+            MaidGuid = meido.Maid.status.guid;
+            maidIndex = meido.Slot;
+        }
+
+        public AttachPointInfo(AttachPoint attachPoint, string maidGuid, int maidIndex)
+        {
+            this.attachPoint = attachPoint;
+            MaidGuid = maidGuid;
+            this.maidIndex = maidIndex;
+        }
+    }
+}

+ 47 - 0
src/MeidoPhotoStudio.Plugin/MeidoPhotoStudio.Plugin.csproj

@@ -0,0 +1,47 @@
+<Project Sdk="Microsoft.Net.Sdk">
+    <PropertyGroup>
+        <TargetFramework>net35</TargetFramework>
+        <AssemblyVersion>0.0</AssemblyVersion>
+        <FileVersion>0.0.0</FileVersion>
+        <GenerateAssemblyInfo>true</GenerateAssemblyInfo>
+        <FrameworkPathOverride Condition="'$(TargetFramework)' == 'net35'">$(MSBuildProgramFiles32)\Reference Assemblies\Microsoft\Framework\.NETFramework\v3.5\Profile\Client</FrameworkPathOverride>
+        <LangVersion>9</LangVersion>
+    </PropertyGroup>
+    <ItemGroup>
+        <Reference Include="Assembly-CSharp">
+            <HintPath>..\..\lib\Assembly-CSharp.dll</HintPath>
+        </Reference>
+        <Reference Include="Assembly-CSharp-firstpass">
+            <HintPath>..\..\lib\Assembly-CSharp-firstpass.dll</HintPath>
+        </Reference>
+        <Reference Include="Assembly-UnityScript-firstpass">
+            <HintPath>..\..\lib\Assembly-UnityScript-firstpass.dll</HintPath>
+        </Reference>
+        <Reference Include="UnityEngine">
+            <HintPath>..\..\lib\UnityEngine.dll</HintPath>
+        </Reference>
+        <Reference Include="Newtonsoft.Json">
+            <HintPath>..\..\lib\Newtonsoft.Json.dll</HintPath>
+        </Reference>
+        <Reference Include="Ionic.Zlib">
+            <HintPath>..\..\lib\Ionic.Zlib.dll</HintPath>
+        </Reference>
+        <Reference Include="BepInEx">
+            <HintPath>..\..\lib\BepInEx.dll</HintPath>
+        </Reference>
+        <Reference Include="0Harmony">
+            <HintPath>..\..\lib\0Harmony.dll</HintPath>
+        </Reference>
+    </ItemGroup>
+    <ItemGroup>
+        <Content Include="Config\**">
+            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+        </Content>
+    </ItemGroup>
+    <ItemGroup>
+      <PackageReference Include="IsExternalInit" Version="1.0.0">
+        <PrivateAssets>all</PrivateAssets>
+        <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+      </PackageReference>
+    </ItemGroup>
+</Project>

+ 491 - 0
src/MeidoPhotoStudio.Plugin/MeidoPhotoStudio.cs

@@ -0,0 +1,491 @@
+using System;
+using System.IO;
+using System.Collections;
+using System.Collections.Generic;
+using System.Text;
+using UnityEngine;
+using UnityEngine.SceneManagement;
+using BepInEx;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    using Input = InputManager;
+    [BepInPlugin(pluginGuid, pluginName, pluginVersion)]
+    [BepInDependency("org.bepinex.plugins.unityinjectorloader", BepInDependency.DependencyFlags.SoftDependency)]
+    public class MeidoPhotoStudio : BaseUnityPlugin
+    {
+        public static readonly byte[] SceneHeader = Encoding.UTF8.GetBytes("MPSSCENE");
+        private static event EventHandler<ScreenshotEventArgs> ScreenshotEvent;
+        private const string pluginGuid = "com.habeebweeb.com3d2.meidophotostudio";
+        public const string pluginName = "MeidoPhotoStudio";
+        public const string pluginVersion = "1.0.0";
+        public const string pluginSubVersion = "beta.3";
+        public const short sceneVersion = 1;
+        public const int kankyoMagic = -765;
+        public static readonly string pluginString = $"{pluginName} {pluginVersion}";
+        public static bool EditMode => currentScene == Constants.Scene.Edit;
+        public static event EventHandler<ScreenshotEventArgs> NotifyRawScreenshot;
+        private WindowManager windowManager;
+        private SceneManager sceneManager;
+        private MeidoManager meidoManager;
+        private EnvironmentManager environmentManager;
+        private MessageWindowManager messageWindowManager;
+        private LightManager lightManager;
+        private PropManager propManager;
+        private EffectManager effectManager;
+        private CameraManager cameraManager;
+        private static Constants.Scene currentScene;
+        private bool initialized;
+        private bool active;
+        private bool uiActive;
+
+        static MeidoPhotoStudio()
+        {
+            Input.Register(MpsKey.Screenshot, KeyCode.S, "Take screenshot");
+            Input.Register(MpsKey.Activate, KeyCode.F6, "Activate/deactivate MeidoPhotoStudio");
+
+            if (!string.IsNullOrEmpty(pluginSubVersion)) pluginString += $"-{pluginSubVersion}";
+        }
+
+        private void Awake()
+        {
+            var harmony = HarmonyLib.Harmony.CreateAndPatchAll(typeof(AllProcPropSeqStartPatcher));
+            harmony.PatchAll(typeof(BgMgrPatcher));
+            ScreenshotEvent += OnScreenshotEvent;
+            DontDestroyOnLoad(this);
+            UnityEngine.SceneManagement.SceneManager.sceneLoaded += OnSceneLoaded;
+            UnityEngine.SceneManagement.SceneManager.activeSceneChanged += OnSceneChanged;
+        }
+
+        private void Start()
+        {
+            Constants.Initialize();
+            Translation.Initialize(Translation.CurrentLanguage);
+        }
+
+        private void OnSceneLoaded(Scene scene, LoadSceneMode sceneMode)
+        {
+            currentScene = (Constants.Scene)scene.buildIndex;
+        }
+
+        private void OnSceneChanged(Scene current, Scene next)
+        {
+            if (active) Deactivate(true);
+            CameraUtility.MainCamera.ResetCalcNearClip();
+        }
+
+        public byte[] SaveScene(bool environment = false)
+        {
+            if (meidoManager.Busy) return null;
+
+            try
+            {
+                using var memoryStream = new MemoryStream();
+                using var headerWriter = new BinaryWriter(memoryStream, Encoding.UTF8);
+
+                headerWriter.Write(SceneHeader);
+
+                new SceneMetadata
+                {
+                    Version = sceneVersion,
+                    Environment = environment,
+                    MaidCount = environment ? kankyoMagic : meidoManager.ActiveMeidoList.Count
+                }.WriteMetadata(headerWriter);
+
+                using var compressionStream = memoryStream.GetCompressionStream();
+                using var dataWriter = new BinaryWriter(compressionStream, Encoding.UTF8);
+
+                if (!environment)
+                {
+                    Serialization.Get<MeidoManager>().Serialize(meidoManager, dataWriter);
+                    Serialization.Get<MessageWindowManager>().Serialize(messageWindowManager, dataWriter);
+                    Serialization.Get<CameraManager>().Serialize(cameraManager, dataWriter);
+                }
+
+                Serialization.Get<LightManager>().Serialize(lightManager, dataWriter);
+                Serialization.Get<EffectManager>().Serialize(effectManager, dataWriter);
+                Serialization.Get<EnvironmentManager>().Serialize(environmentManager, dataWriter);
+                Serialization.Get<PropManager>().Serialize(propManager, dataWriter);
+
+                dataWriter.Write("END");
+
+                compressionStream.Close();
+
+                var data = memoryStream.ToArray();
+                return data;
+            }
+            catch (Exception e)
+            {
+                Utility.LogError($"Failed to save scene because {e.Message}\n{e.StackTrace}");
+                return null;
+            }
+        }
+
+        public void LoadScene(byte[] buffer)
+        {
+            if (meidoManager.Busy)
+            {
+                Utility.LogMessage("Could not apply scene. Meidos are Busy");
+                return;
+            }
+
+            using var memoryStream = new MemoryStream(buffer);
+            using var headerReader = new BinaryReader(memoryStream, Encoding.UTF8);
+            
+            if (!Utility.BytesEqual(headerReader.ReadBytes(SceneHeader.Length), SceneHeader))
+            {
+                Utility.LogError("Not a MPS scene!");
+                return;
+            }
+
+            var metadata = SceneMetadata.ReadMetadata(headerReader);
+
+            using var uncompressed = memoryStream.Decompress();
+            using var dataReader = new BinaryReader(uncompressed, Encoding.UTF8);
+
+            var header = string.Empty;
+            var previousHeader = string.Empty;
+            try
+            {
+                while ((header = dataReader.ReadString()) != "END")
+                {
+                    switch (header)
+                    {
+                        case MeidoManager.header: 
+                            Serialization.Get<MeidoManager>().Deserialize(meidoManager, dataReader, metadata);
+                            break;
+                        case MessageWindowManager.header:
+                            Serialization.Get<MessageWindowManager>().Deserialize(messageWindowManager, dataReader, metadata);
+                            break;
+                        case CameraManager.header:
+                            Serialization.Get<CameraManager>().Deserialize(cameraManager, dataReader, metadata);
+                            break;
+                        case LightManager.header:
+                            Serialization.Get<LightManager>().Deserialize(lightManager, dataReader, metadata);
+                            break;
+                        case EffectManager.header:
+                            Serialization.Get<EffectManager>().Deserialize(effectManager, dataReader, metadata);
+                            break;
+                        case EnvironmentManager.header:
+                            Serialization.Get<EnvironmentManager>().Deserialize(environmentManager, dataReader, metadata);
+                            break;
+                        case PropManager.header:
+                            Serialization.Get<PropManager>().Deserialize(propManager, dataReader, metadata);
+                            break;
+                        default: throw new Exception($"Unknown header '{header}'");
+                    }
+
+                    previousHeader = header;
+                }
+            }
+            catch (Exception e)
+            {
+                Utility.LogError(
+                    $"Failed to deserialize scene TEST because {e.Message}"
+                    + $"\nCurrent header: '{header}'. Last header: '{previousHeader}'"
+                );
+                Utility.LogError(e.StackTrace);
+            }
+        }
+
+        public static void TakeScreenshot(ScreenshotEventArgs args) => ScreenshotEvent?.Invoke(null, args);
+
+        public static void TakeScreenshot(string path = "", int superSize = -1, bool hideMaids = false)
+        {
+            TakeScreenshot(new ScreenshotEventArgs() { Path = path, SuperSize = superSize, HideMaids = hideMaids });
+        }
+
+        private void OnScreenshotEvent(object sender, ScreenshotEventArgs args)
+        {
+            StartCoroutine(Screenshot(args));
+        }
+
+        private void Update()
+        {
+            if (currentScene == Constants.Scene.Daily || currentScene == Constants.Scene.Edit)
+            {
+                if (Input.GetKeyDown(MpsKey.Activate))
+                {
+                    if (active) Deactivate();
+                    else Activate();
+                }
+
+                if (active)
+                {
+                    if (!Input.Control && !Input.GetKey(MpsKey.CameraLayer) && Input.GetKeyDown(MpsKey.Screenshot))
+                    {
+                        TakeScreenshot();
+                    }
+
+                    meidoManager.Update();
+                    cameraManager.Update();
+                    windowManager.Update();
+                    effectManager.Update();
+                    sceneManager.Update();
+                }
+            }
+        }
+
+        private IEnumerator Screenshot(ScreenshotEventArgs args)
+        {
+            // Hide UI and dragpoints
+            GameObject gameMain = GameMain.Instance.gameObject;
+            GameObject editUI = UTY.GetChildObject(GameObject.Find("UI Root"), "Camera");
+            GameObject fpsViewer = UTY.GetChildObject(gameMain, "SystemUI Root/FpsCounter");
+            GameObject sysDialog = UTY.GetChildObject(gameMain, "SystemUI Root/SystemDialog");
+            GameObject sysShortcut = UTY.GetChildObject(gameMain, "SystemUI Root/SystemShortcut");
+
+            // CameraUtility can hide the edit UI so keep its state for later
+            bool editUIWasActive = editUI.activeSelf;
+
+            uiActive = false;
+            editUI.SetActive(false);
+            fpsViewer.SetActive(false);
+            sysDialog.SetActive(false);
+            sysShortcut.SetActive(false);
+
+            // Hide maid dragpoints and maids
+            List<Meido> activeMeidoList = meidoManager.ActiveMeidoList;
+            bool[] isIK = new bool[activeMeidoList.Count];
+            bool[] isVisible = new bool[activeMeidoList.Count];
+            for (int i = 0; i < activeMeidoList.Count; i++)
+            {
+                Meido meido = activeMeidoList[i];
+                isIK[i] = meido.IK;
+                if (meido.IK) meido.IK = false;
+
+                // Hide the maid if needed
+                if (args.HideMaids)
+                {
+                    isVisible[i] = meido.Maid.Visible;
+                    meido.Maid.Visible = false;
+                }
+            }
+
+            // Hide other drag points
+            bool[] isCubeActive = {
+                MeidoDragPointManager.CubeActive,
+                PropManager.CubeActive,
+                LightManager.CubeActive,
+                EnvironmentManager.CubeActive
+            };
+
+            MeidoDragPointManager.CubeActive = false;
+            PropManager.CubeActive = false;
+            LightManager.CubeActive = false;
+            EnvironmentManager.CubeActive = false;
+
+            // hide gizmos
+            GizmoRender.UIVisible = false;
+
+            yield return new WaitForEndOfFrame();
+
+            Texture2D rawScreenshot = null;
+
+            if (args.InMemory)
+            {
+                // Take a screenshot directly to a Texture2D for immediate processing
+                RenderTexture renderTexture = new RenderTexture(Screen.width, Screen.height, 24);
+                RenderTexture.active = renderTexture;
+                CameraUtility.MainCamera.camera.targetTexture = renderTexture;
+                CameraUtility.MainCamera.camera.Render();
+
+                rawScreenshot = new Texture2D(Screen.width, Screen.height, TextureFormat.RGB24, false);
+                rawScreenshot.ReadPixels(new Rect(0, 0, renderTexture.width, renderTexture.height), 0, 0, false);
+                rawScreenshot.Apply();
+
+                CameraUtility.MainCamera.camera.targetTexture = null;
+                RenderTexture.active = null;
+                DestroyImmediate(renderTexture);
+            }
+            else
+            {
+                // Take Screenshot
+                int[] defaultSuperSize = new[] { 1, 2, 4 };
+                int selectedSuperSize = args.SuperSize < 1
+                    ? defaultSuperSize[(int)GameMain.Instance.CMSystem.ScreenShotSuperSize]
+                    : args.SuperSize;
+
+                string path = string.IsNullOrEmpty(args.Path)
+                    ? Utility.ScreenshotFilename()
+                    : args.Path;
+
+                Application.CaptureScreenshot(path, selectedSuperSize);
+            }
+            GameMain.Instance.SoundMgr.PlaySe("se022.ogg", false);
+
+            yield return new WaitForEndOfFrame();
+
+            // Show UI and dragpoints
+            uiActive = true;
+            editUI.SetActive(editUIWasActive);
+            fpsViewer.SetActive(GameMain.Instance.CMSystem.ViewFps);
+            sysDialog.SetActive(true);
+            sysShortcut.SetActive(true);
+
+            for (int i = 0; i < activeMeidoList.Count; i++)
+            {
+                Meido meido = activeMeidoList[i];
+                if (isIK[i]) meido.IK = true;
+                if (args.HideMaids && isVisible[i]) meido.Maid.Visible = true;
+            }
+
+            MeidoDragPointManager.CubeActive = isCubeActive[0];
+            PropManager.CubeActive = isCubeActive[1];
+            LightManager.CubeActive = isCubeActive[2];
+            EnvironmentManager.CubeActive = isCubeActive[3];
+
+            GizmoRender.UIVisible = true;
+
+            if (args.InMemory && rawScreenshot)
+                NotifyRawScreenshot?.Invoke(null, new ScreenshotEventArgs() { Screenshot = rawScreenshot });
+        }
+
+        private void OnGUI()
+        {
+            if (uiActive)
+            {
+                windowManager.DrawWindows();
+
+                if (DropdownHelper.Visible) DropdownHelper.HandleDropdown();
+                if (Modal.Visible) Modal.Draw();
+            }
+        }
+
+        private void Initialize()
+        {
+            if (initialized) return;
+            initialized = true;
+
+            meidoManager = new MeidoManager();
+            environmentManager = new EnvironmentManager();
+            messageWindowManager = new MessageWindowManager();
+            lightManager = new LightManager();
+            propManager = new PropManager(meidoManager);
+            sceneManager = new SceneManager(this);
+            cameraManager = new CameraManager();
+
+            effectManager = new EffectManager();
+            effectManager.AddManager<BloomEffectManager>();
+            effectManager.AddManager<DepthOfFieldEffectManager>();
+            effectManager.AddManager<FogEffectManager>();
+            effectManager.AddManager<VignetteEffectManager>();
+            effectManager.AddManager<SepiaToneEffectManger>();
+            effectManager.AddManager<BlurEffectManager>();
+
+            meidoManager.BeginCallMeidos += (s, a) => uiActive = false;
+            meidoManager.EndCallMeidos += (s, a) => uiActive = true;
+
+            MaidSwitcherPane maidSwitcherPane = new MaidSwitcherPane(meidoManager);
+
+            SceneWindow sceneWindow = new SceneWindow(sceneManager);
+
+            windowManager = new WindowManager()
+            {
+                [Constants.Window.Main] = new MainWindow(meidoManager, propManager, lightManager)
+                {
+                    [Constants.Window.Call] = new CallWindowPane(meidoManager),
+                    [Constants.Window.Pose] = new PoseWindowPane(meidoManager, maidSwitcherPane),
+                    [Constants.Window.Face] = new FaceWindowPane(meidoManager, maidSwitcherPane),
+                    [Constants.Window.BG] = new BGWindowPane(
+                        environmentManager, lightManager, effectManager, sceneWindow, cameraManager
+                    ),
+                    [Constants.Window.BG2] = new BG2WindowPane(meidoManager, propManager),
+                    [Constants.Window.Settings] = new SettingsWindowPane()
+                },
+                [Constants.Window.Message] = new MessageWindow(messageWindowManager),
+                [Constants.Window.Save] = sceneWindow
+            };
+        }
+
+        private void Activate()
+        {
+            if (!GameMain.Instance.SysDlg.IsDecided) return;
+
+            if (!initialized) Initialize();
+            else
+            {
+                meidoManager.Activate();
+                environmentManager.Activate();
+                cameraManager.Activate();
+                propManager.Activate();
+                lightManager.Activate();
+                effectManager.Activate();
+                messageWindowManager.Activate();
+                windowManager.Activate();
+            }
+
+            uiActive = true;
+            active = true;
+
+            if (!EditMode)
+            {
+                GameObject dailyPanel = GameObject.Find("UI Root")?.transform.Find("DailyPanel")?.gameObject;
+                if (dailyPanel) dailyPanel.SetActive(false);
+            }
+            else meidoManager.CallMeidos();
+        }
+
+        private void Deactivate(bool force = false)
+        {
+            if (meidoManager.Busy || SceneManager.Busy) return;
+
+            SystemDialog sysDialog = GameMain.Instance.SysDlg;
+
+            void exit()
+            {
+                sysDialog.Close();
+
+                meidoManager.Deactivate();
+                environmentManager.Deactivate();
+                cameraManager.Deactivate();
+                propManager.Deactivate();
+                lightManager.Deactivate();
+                effectManager.Deactivate();
+                messageWindowManager.Deactivate();
+                windowManager.Deactivate();
+                Input.Deactivate();
+
+                Modal.Close();
+
+                if (!EditMode)
+                {
+                    GameObject dailyPanel = GameObject.Find("UI Root")?.transform.Find("DailyPanel")?.gameObject;
+                    dailyPanel?.SetActive(true);
+                }
+
+                Configuration.Config.Save();
+            }
+
+            if (sysDialog.IsDecided || EditMode || force)
+            {
+                uiActive = false;
+                active = false;
+
+                if (EditMode || force) exit();
+                else
+                {
+                    string exitMessage = string.Format(Translation.Get("systemMessage", "exitConfirm"), pluginName);
+                    sysDialog.Show(exitMessage, SystemDialog.TYPE.OK_CANCEL,
+                        f_dgOk: exit,
+                        f_dgCancel: () =>
+                        {
+                            sysDialog.Close();
+                            uiActive = true;
+                            active = true;
+                        }
+                    );
+                }
+            }
+        }
+    }
+
+    public class ScreenshotEventArgs : EventArgs
+    {
+        public string Path { get; set; } = string.Empty;
+        public int SuperSize { get; set; } = -1;
+        public bool HideMaids { get; set; }
+        public bool InMemory { get; set; } = false;
+        public Texture2D Screenshot { get; set; }
+    }
+}

+ 62 - 0
src/MeidoPhotoStudio.Plugin/MenuFileCache.cs

@@ -0,0 +1,62 @@
+using System.IO;
+using System.Collections.Generic;
+
+/*
+    All of this is pretty much stolen from COM3D2.CacheEditMenu. Thanks Mr. Horsington.
+    https://git.coder.horse/ghorsington/COM3D2.CacheEditMenu
+*/
+namespace MeidoPhotoStudio.Plugin
+{
+    using static MenuFileUtility;
+
+    public class MenuFileCache
+    {
+        private const int cacheVersion = 765;
+        public static readonly string cachePath = Path.Combine(Constants.configPath, "cache.dat");
+        private readonly Dictionary<string, ModItem> modItems;
+        private bool rebuild;
+        public ModItem this[string menu]
+        {
+            get => modItems[menu];
+            set
+            {
+                if (!modItems.ContainsKey(menu))
+                {
+                    rebuild = true;
+                    modItems[menu] = value;
+                }
+            }
+        }
+
+        public MenuFileCache()
+        {
+            modItems = new Dictionary<string, ModItem>();
+            if (File.Exists(cachePath)) Deserialize();
+        }
+
+        public bool Has(string menuFileName) => modItems.ContainsKey(menuFileName);
+
+        private void Deserialize()
+        {
+            using BinaryReader binaryReader = new BinaryReader(File.OpenRead(cachePath));
+            if (binaryReader.ReadInt32() != cacheVersion)
+            {
+                Utility.LogInfo("Cache version out of date. Rebuilding");
+                return;
+            }
+            while (binaryReader.BaseStream.Position < binaryReader.BaseStream.Length)
+            {
+                ModItem item = ModItem.Deserialize(binaryReader);
+                modItems[item.MenuFile] = item;
+            }
+        }
+
+        public void Serialize()
+        {
+            if (!rebuild) return;
+            using BinaryWriter binaryWriter = new BinaryWriter(File.OpenWrite(cachePath));
+            binaryWriter.Write(cacheVersion);
+            foreach (ModItem item in modItems.Values) item.Serialize(binaryWriter);
+        }
+    }
+}

+ 233 - 0
src/MeidoPhotoStudio.Plugin/MenuFileUtility.cs

@@ -0,0 +1,233 @@
+using System;
+using System.Text;
+using System.IO;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public static class MenuFileUtility
+    {
+        private static byte[] fileBuffer;
+        public const string noCategory = "noCategory";
+
+        public static readonly string[] MenuCategories =
+        {
+            noCategory, "acchat", "headset", "wear", "skirt", "onepiece", "mizugi", "bra", "panz", "stkg", "shoes",
+            "acckami", "megane", "acchead", "acchana", "accmimi", "glove", "acckubi", "acckubiwa", "acckamisub",
+            "accnip", "accude", "accheso", "accashi", "accsenaka", "accshippo", "accxxx"
+        };
+
+        private static readonly HashSet<string> accMpn = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);
+
+        public static event EventHandler MenuFilesReadyChange;
+        public static bool MenuFilesReady { get; private set; }
+
+        static MenuFileUtility()
+        {
+            accMpn.UnionWith(MenuCategories.Skip(1));
+            GameMain.Instance.StartCoroutine(CheckMenuDataBaseJob());
+        }
+
+        private static IEnumerator CheckMenuDataBaseJob()
+        {
+            if (MenuFilesReady) yield break;
+            while (!GameMain.Instance.MenuDataBase.JobFinished()) yield return null;
+            MenuFilesReady = true;
+            MenuFilesReadyChange?.Invoke(null, EventArgs.Empty);
+        }
+
+        private static ref byte[] GetFileBuffer(long size)
+        {
+            if (fileBuffer == null) fileBuffer = new byte[Math.Max(500000, size)];
+            else if (fileBuffer.Length < size) fileBuffer = new byte[size];
+
+            return ref fileBuffer;
+        }
+
+        public static byte[] ReadAFileBase(string filename)
+        {
+            using AFileBase aFileBase = GameUty.FileOpen(filename);
+
+            if (aFileBase.IsValid() && aFileBase.GetSize() != 0)
+            {
+                ref byte[] buffer = ref GetFileBuffer(aFileBase.GetSize());
+
+                aFileBase.Read(ref buffer, aFileBase.GetSize());
+
+                return buffer;
+            }
+
+            Utility.LogError($"AFileBase '{filename}' is invalid");
+            return null;
+        }
+
+        public static byte[] ReadOfficialMod(string filename)
+        {
+            using var fileStream = new FileStream(filename, FileMode.Open);
+
+            if (fileStream.Length != 0)
+            {
+                ref byte[] buffer = ref GetFileBuffer(fileStream.Length);
+
+                fileStream.Read(buffer, 0, (int) fileStream.Length);
+
+                return buffer;
+            }
+
+            Utility.LogWarning($"Mod menu file '{filename}' is invalid");
+            return null;
+        }
+
+        public static bool ParseNativeMenuFile(int menuIndex, ModItem modItem)
+        {
+            MenuDataBase menuDataBase = GameMain.Instance.MenuDataBase;
+            menuDataBase.SetIndex(menuIndex);
+            if (menuDataBase.GetBoDelOnly()) return false;
+            modItem.Category = menuDataBase.GetCategoryMpnText();
+            if (!accMpn.Contains(modItem.Category)) return false;
+            modItem.MenuFile = menuDataBase.GetMenuFileName().ToLower();
+            if (!ValidBG2MenuFile(modItem.MenuFile)) return false;
+            modItem.Name = menuDataBase.GetMenuName();
+            modItem.IconFile = menuDataBase.GetIconS();
+            modItem.Priority = menuDataBase.GetPriority();
+            return true;
+        }
+
+        public static void ParseMenuFile(string menuFile, ModItem modItem)
+        {
+            if (!ValidBG2MenuFile(menuFile)) return;
+
+            byte[] buffer;
+
+            try { buffer = ReadAFileBase(menuFile); }
+            catch (Exception e)
+            {
+                Utility.LogError($"Could not read menu file '{menuFile}' because {e.Message}");
+                return ;
+            }
+
+            try
+            {
+                using var binaryReader = new BinaryReader(new MemoryStream(buffer), Encoding.UTF8);
+
+                if (binaryReader.ReadString() != "CM3D2_MENU") return;
+                binaryReader.ReadInt32(); // file version
+                binaryReader.ReadString(); // txt path
+                modItem.Name = binaryReader.ReadString(); // name
+                binaryReader.ReadString(); // category
+                binaryReader.ReadString(); // description
+                binaryReader.ReadInt32(); // idk (as long)
+
+                while (true)
+                {
+                    int numberOfProps = binaryReader.ReadByte();
+                    var menuPropString = string.Empty;
+
+                    if (numberOfProps == 0) break;
+
+                    for (var i = 0; i < numberOfProps; i++)
+                    {
+                        menuPropString = $"{menuPropString}\"{binaryReader.ReadString()}\"";
+                    }
+
+                    if (string.IsNullOrEmpty(menuPropString)) continue;
+
+                    var header = UTY.GetStringCom(menuPropString);
+                    string[] menuProps = UTY.GetStringList(menuPropString);
+
+                    if (header == "end") break;
+
+                    if (header == "category")
+                    {
+                        modItem.Category = menuProps[1];
+                        if (!accMpn.Contains(modItem.Category)) return;
+                    }
+                    else if (header == "icons" || header == "icon")
+                    {
+                        modItem.IconFile = menuProps[1];
+                        break;
+                    }
+                    else if (header == "priority") modItem.Priority = float.Parse(menuProps[1]);
+                }
+            }
+            catch (Exception e)
+            {
+                Utility.LogWarning($"Could not parse menu file '{menuFile}' because {e.Message}");
+            }
+        }
+
+        public static bool ParseModMenuFile(string modMenuFile, ModItem modItem)
+        {
+            if (!ValidBG2MenuFile(modMenuFile)) return false;
+
+            byte[] modBuffer;
+
+            try { modBuffer = ReadOfficialMod(modMenuFile); }
+            catch (Exception e)
+            {
+                Utility.LogError($"Could not read mod menu file '{modMenuFile} because {e.Message}'");
+                return false;
+            }
+
+            try
+            {
+                using var binaryReader = new BinaryReader(new MemoryStream(modBuffer), Encoding.UTF8);
+
+                if (binaryReader.ReadString() != "CM3D2_MOD") return false;
+
+                binaryReader.ReadInt32();
+                var iconName = binaryReader.ReadString();
+                var baseItemPath = binaryReader.ReadString().Replace(":", " ");
+                modItem.BaseMenuFile = Path.GetFileName(baseItemPath);
+                modItem.Name = binaryReader.ReadString();
+                modItem.Category = binaryReader.ReadString();
+                if (!accMpn.Contains(modItem.Category)) return false;
+                binaryReader.ReadString();
+
+                var mpnValue = binaryReader.ReadString();
+                var mpn = MPN.null_mpn;
+                try { mpn = (MPN) Enum.Parse(typeof(MPN), mpnValue, true); }
+                catch { /* ignored */ }
+
+                if (mpn != MPN.null_mpn) binaryReader.ReadString();
+
+                binaryReader.ReadString();
+
+                var entryCount = binaryReader.ReadInt32();
+                for (var i = 0; i < entryCount; i++)
+                {
+                    var key = binaryReader.ReadString();
+                    var count = binaryReader.ReadInt32();
+                    byte[] data = binaryReader.ReadBytes(count);
+
+                    if (!string.Equals(key, iconName, StringComparison.InvariantCultureIgnoreCase)) continue;
+
+                    var tex = new Texture2D(1, 1, TextureFormat.RGBA32, false);
+                    tex.LoadImage(data);
+                    modItem.Icon = tex;
+                    break;
+                }
+            }
+            catch (Exception e)
+            {
+                Utility.LogWarning($"Could not parse mod menu file '{modMenuFile}' because {e}");
+                return false;
+            }
+
+            return true;
+        }
+
+        public static bool ValidBG2MenuFile(ModItem modItem)
+            => accMpn.Contains(modItem.Category) && ValidBG2MenuFile(modItem.MenuFile);
+
+        public static bool ValidBG2MenuFile(string menu)
+        {
+            menu = Path.GetFileNameWithoutExtension(menu).ToLower();
+            return !(menu.EndsWith("_del") || menu.Contains("zurashi") || menu.Contains("mekure")
+                || menu.Contains("porori") || menu.Contains("moza") || menu.Contains("folder"));
+        }
+    }
+}

+ 70 - 0
src/MeidoPhotoStudio.Plugin/MenuItem.cs

@@ -0,0 +1,70 @@
+using System.Globalization;
+using System.IO;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public abstract class MenuItem
+    {
+        public string IconFile { get; set; }
+        public Texture2D Icon { get; set; }
+    }
+
+    public class ModItem : MenuItem
+    {
+        public string MenuFile { get; set; }
+        public string BaseMenuFile { get; set; }
+        public string Name { get; set; }
+        public string Category { get; set; }
+        public float Priority { get; set; }
+        public bool IsMod { get; private set; }
+        public bool IsOfficialMod { get; private set; }
+
+        public static ModItem OfficialMod(string menuFile) => new ModItem()
+        {
+            MenuFile = menuFile, IsMod = true, IsOfficialMod = true, Priority = 1000f
+        };
+
+        public static ModItem Mod(string menuFile) => new ModItem() { MenuFile = menuFile, IsMod = true };
+
+        public ModItem() { }
+
+        public ModItem(string menuFile) => MenuFile = menuFile;
+
+        public override string ToString() => IsOfficialMod ? $"{Path.GetFileName(MenuFile)}#{BaseMenuFile}" : MenuFile;
+
+        public static ModItem Deserialize(BinaryReader binaryReader) => new ModItem()
+        {
+            MenuFile = binaryReader.ReadNullableString(),
+            BaseMenuFile = binaryReader.ReadNullableString(),
+            IconFile = binaryReader.ReadNullableString(),
+            Name = binaryReader.ReadNullableString(),
+            Category = binaryReader.ReadNullableString(),
+            Priority = float.Parse(binaryReader.ReadNullableString()),
+            IsMod = binaryReader.ReadBoolean(),
+            IsOfficialMod = binaryReader.ReadBoolean()
+        };
+
+        public void Serialize(BinaryWriter binaryWriter)
+        {
+            if (IsOfficialMod) return;
+
+            binaryWriter.WriteNullableString(MenuFile);
+            binaryWriter.WriteNullableString(BaseMenuFile);
+            binaryWriter.WriteNullableString(IconFile);
+            binaryWriter.WriteNullableString(Name);
+            binaryWriter.WriteNullableString(Category);
+            binaryWriter.WriteNullableString(Priority.ToString(CultureInfo.InvariantCulture));
+            binaryWriter.Write(IsMod);
+            binaryWriter.Write(IsOfficialMod);
+        }
+    }
+
+    public class MyRoomItem : MenuItem
+    {
+        public int ID { get; set; }
+        public string PrefabName { get; set; }
+
+        public override string ToString() => $"MYR_{ID}#{PrefabName}";
+    }
+}

+ 565 - 0
src/MeidoPhotoStudio.Plugin/ModelUtility.cs

@@ -0,0 +1,565 @@
+using System.IO;
+using System.Text;
+using System.Collections.Generic;
+using UnityEngine;
+using UnityEngine.Rendering;
+using System;
+using System.Linq;
+using Object = UnityEngine.Object;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    using static MenuFileUtility;
+
+    public static class ModelUtility
+    {
+        private enum IMode { None, ItemChange, TexChange }
+
+        private static GameObject deploymentObject;
+
+        private static GameObject GetDeploymentObject()
+        {
+            if (deploymentObject) return deploymentObject;
+
+            if (!(deploymentObject = GameObject.Find("Deployment Object Parent")))
+                deploymentObject = new GameObject("Deployment Object Parent");
+            return deploymentObject;
+        }
+
+        public static GameObject LoadMyRoomModel(MyRoomItem item)
+        {
+            var data = MyRoomCustom.PlacementData.GetData(item.ID);
+            var gameObject = Object.Instantiate(data.GetPrefab());
+            if (gameObject)
+            {
+                var final = new GameObject();
+                gameObject.transform.SetParent(final.transform, true);
+                final.transform.SetParent(GetDeploymentObject().transform, false);
+                return final;
+            }
+
+            Utility.LogMessage($"Could not load MyRoomCreative model '{item.PrefabName}'");
+
+            return null;
+        }
+
+        public static GameObject LoadBgModel(string bgName)
+        {
+            var gameObject = GameMain.Instance.BgMgr.CreateAssetBundle(bgName);
+            if (!gameObject) gameObject = Resources.Load<GameObject>("BG/" + bgName);
+            if (!gameObject) gameObject = Resources.Load<GameObject>("BG/2_0/" + bgName);
+
+            if (gameObject)
+            {
+                var final = Object.Instantiate(gameObject);
+                final.transform.localScale = Vector3.one * 0.1f;
+                return final;
+            }
+
+            Utility.LogMessage($"Could not load BG model '{bgName}'");
+
+            return null;
+        }
+
+        public static GameObject LoadGameModel(string assetName)
+        {
+            var gameObject = GameMain.Instance.BgMgr.CreateAssetBundle(assetName);
+            if (!gameObject) gameObject = Resources.Load<GameObject>("Prefab/" + assetName);
+            if (!gameObject) gameObject = Resources.Load<GameObject>("BG/" + assetName);
+
+            if (gameObject)
+            {
+                var final = Object.Instantiate(gameObject);
+                final.transform.localPosition = Vector3.zero;
+
+                Renderer[] renderers = final.GetComponentsInChildren<Renderer>();
+                foreach (var renderer in renderers)
+                {
+                    if (renderer && renderer.gameObject.name.Contains("castshadow"))
+                        renderer.shadowCastingMode = ShadowCastingMode.Off;
+                }
+
+                Collider[] colliders = final.GetComponentsInChildren<Collider>();
+                foreach (var collider in colliders)
+                {
+                    if (collider) collider.enabled = false;
+                }
+
+                if (final.transform.localScale != Vector3.one)
+                {
+                    var parent = new GameObject();
+                    final.transform.SetParent(parent.transform, true);
+                    return parent;
+                }
+
+                return final;
+            }
+
+            Utility.LogMessage($"Could not load game model '{assetName}'");
+
+            return null;
+        }
+
+        public static GameObject LoadMenuModel(string menuFile) => LoadMenuModel(new ModItem(menuFile));
+
+        public static GameObject LoadMenuModel(ModItem modItem)
+        {
+            var menu = modItem.IsOfficialMod ? modItem.BaseMenuFile : modItem.MenuFile;
+
+            byte[] modelBuffer;
+
+            try { modelBuffer = ReadAFileBase(menu); }
+            catch (Exception e)
+            {
+                Utility.LogError($"Could not read menu file '{menu}' because {e.Message}\n{e.StackTrace}");
+                return null;
+            }
+
+            if (ProcScriptBin(modelBuffer, out ModelInfo modelInfo))
+            {
+                if (InstantiateModel(modelInfo.ModelFile, out var finalModel))
+                {
+                    IEnumerable<Renderer> renderers = GetRenderers(finalModel).ToList();
+
+                    foreach (MaterialChange matChange in modelInfo.MaterialChanges)
+                    {
+                        foreach (Renderer renderer in renderers)
+                        {
+                            if (matChange.MaterialIndex < renderer.materials.Length)
+                            {
+                                renderer.materials[matChange.MaterialIndex] = ImportCM.LoadMaterial(
+                                    matChange.MaterialFile, null, renderer.materials[matChange.MaterialIndex]
+                                );
+                            }
+                        }
+                    }
+
+                    if (!modItem.IsOfficialMod) return finalModel;
+
+                    try { modelBuffer = ReadOfficialMod(modItem.MenuFile); }
+                    catch (Exception e)
+                    {
+                        Utility.LogError(
+                            $"Could not read mod menu file '{modItem.MenuFile}' because {e.Message}\n{e.StackTrace}"
+                        );
+                        return null;
+                    }
+
+                    ProcModScriptBin(modelBuffer, finalModel);
+
+                    return finalModel;
+                }
+            }
+
+            Utility.LogMessage($"Could not load menu model '{modItem.MenuFile}'");
+
+            return null;
+        }
+
+        private static IEnumerable<Renderer> GetRenderers(GameObject gameObject) => gameObject.transform
+            .GetComponentsInChildren<Transform>(true)
+            .Select(transform => transform.GetComponent<Renderer>())
+            .Where(renderer => renderer && renderer.material).ToList();
+
+        private static GameObject CreateSeed() => Object.Instantiate(Resources.Load<GameObject>("seed"));
+
+        private static bool InstantiateModel(string modelFilename, out GameObject modelParent)
+        {
+            byte[] buffer;
+
+            modelParent = default;
+
+            try { buffer = ReadAFileBase(modelFilename); }
+            catch
+            {
+                Utility.LogError($"Could not load model file '{modelFilename}'");
+                return false;
+            }
+
+            using var binaryReader = new BinaryReader(new MemoryStream(buffer), Encoding.UTF8);
+
+            if (binaryReader.ReadString() != "CM3D2_MESH")
+            {
+                Utility.LogError($"{modelFilename} is not a model file");
+                return false;
+            }
+
+            var modelVersion = binaryReader.ReadInt32();
+            var modelName = binaryReader.ReadString();
+
+            modelParent = CreateSeed();
+            modelParent.layer = 1;
+            modelParent.name = "_SM_" + modelName;
+
+            var rootName = binaryReader.ReadString();
+            var boneCount = binaryReader.ReadInt32();
+
+            var boneDict = new Dictionary<string, GameObject>();
+            var boneList = new List<GameObject>(boneCount);
+
+            GameObject rootBone = null;
+
+            try
+            {
+                // read bone data
+                for (var i = 0; i < boneCount; i++)
+                {
+                    GameObject bone = CreateSeed();
+                    bone.layer = 1;
+                    bone.name = binaryReader.ReadString();
+
+                    if (binaryReader.ReadByte() != 0)
+                    {
+                        GameObject otherBone = CreateSeed();
+                        otherBone.name = bone.name + "_SCL_";
+                        otherBone.transform.parent = bone.transform;
+                        boneDict[bone.name + "$_SCL_"] = otherBone;
+                    }
+
+                    boneList.Add(bone);
+                    boneDict[bone.name] = bone;
+
+                    if (bone.name == rootName) rootBone = bone;
+                }
+
+                for (var i = 0; i < boneCount; i++)
+                {
+                    var parentIndex = binaryReader.ReadInt32();
+                    boneList[i].transform.parent = parentIndex >= 0
+                        ? boneList[parentIndex].transform
+                        : modelParent.transform;
+                }
+
+                for (var i = 0; i < boneCount; i++)
+                {
+                    Transform transform = boneList[i].transform;
+                    transform.localPosition = binaryReader.ReadVector3();
+                    transform.localRotation = binaryReader.ReadQuaternion();
+                    if (modelVersion >= 2001 && binaryReader.ReadBoolean())
+                        transform.localScale = binaryReader.ReadVector3();
+                }
+
+                // read mesh data
+                var meshRenderer = rootBone.AddComponent<SkinnedMeshRenderer>();
+                meshRenderer.updateWhenOffscreen = true;
+                meshRenderer.skinnedMotionVectors = false;
+                meshRenderer.lightProbeUsage = LightProbeUsage.Off;
+                meshRenderer.reflectionProbeUsage = ReflectionProbeUsage.Off;
+                meshRenderer.motionVectorGenerationMode = MotionVectorGenerationMode.ForceNoMotion;
+
+                Mesh sharedMesh = meshRenderer.sharedMesh = new Mesh();
+
+                var vertCount = binaryReader.ReadInt32();
+                var subMeshCount = binaryReader.ReadInt32();
+                var meshBoneCount = binaryReader.ReadInt32();
+
+                var meshBones = new Transform[meshBoneCount];
+                for (var i = 0; i < meshBoneCount; i++)
+                {
+                    var boneName = binaryReader.ReadString();
+
+                    if (!boneDict.ContainsKey(boneName))
+                        Debug.LogError("nullbone= " + boneName);
+                    else
+                    {
+                        var keyName = boneName + "$_SCL_";
+                        GameObject bone = boneDict.ContainsKey(keyName) ? boneDict[keyName] : boneDict[boneName];
+                        meshBones[i] = bone.transform;
+                    }
+                }
+
+                meshRenderer.bones = meshBones;
+
+                var bindPoses = new Matrix4x4[meshBoneCount];
+                for (var i = 0; i < meshBoneCount; i++) bindPoses[i] = binaryReader.ReadMatrix4x4();
+
+                sharedMesh.bindposes = bindPoses;
+
+                var vertices = new Vector3[vertCount];
+                var normals = new Vector3[vertCount];
+                var uv = new Vector2[vertCount];
+
+                for (var i = 0; i < vertCount; i++)
+                {
+                    vertices[i] = binaryReader.ReadVector3();
+                    normals[i] = binaryReader.ReadVector3();
+                    uv[i] = binaryReader.ReadVector2();
+                }
+
+                sharedMesh.vertices = vertices;
+                sharedMesh.normals = normals;
+                sharedMesh.uv = uv;
+
+                var tangentCount = binaryReader.ReadInt32();
+                if (tangentCount > 0)
+                {
+                    var tangents = new Vector4[tangentCount];
+                    for (var i = 0; i < tangentCount; i++) tangents[i] = binaryReader.ReadVector4();
+                    sharedMesh.tangents = tangents;
+                }
+
+                var boneWeights = new BoneWeight[vertCount];
+                for (var i = 0; i < vertCount; i++)
+                {
+                    boneWeights[i].boneIndex0 = binaryReader.ReadUInt16();
+                    boneWeights[i].boneIndex1 = binaryReader.ReadUInt16();
+                    boneWeights[i].boneIndex2 = binaryReader.ReadUInt16();
+                    boneWeights[i].boneIndex3 = binaryReader.ReadUInt16();
+                    boneWeights[i].weight0 = binaryReader.ReadSingle();
+                    boneWeights[i].weight1 = binaryReader.ReadSingle();
+                    boneWeights[i].weight2 = binaryReader.ReadSingle();
+                    boneWeights[i].weight3 = binaryReader.ReadSingle();
+                }
+
+                sharedMesh.boneWeights = boneWeights;
+                sharedMesh.subMeshCount = subMeshCount;
+
+                for (var i = 0; i < subMeshCount; i++)
+                {
+                    var pointCount = binaryReader.ReadInt32();
+                    var triangles = new int[pointCount];
+                    for (var j = 0; j < pointCount; j++) triangles[j] = binaryReader.ReadUInt16();
+                    sharedMesh.SetTriangles(triangles, i);
+                }
+
+                // read materials
+                var materialCount = binaryReader.ReadInt32();
+
+                var materials = new Material[materialCount];
+
+                for (var i = 0; i < materialCount; i++) materials[i] = ImportCM.ReadMaterial(binaryReader);
+
+                meshRenderer.materials = materials;
+
+                modelParent.AddComponent<Animation>();
+
+                return true;
+            }
+            catch (Exception e)
+            {
+                Utility.LogError($"Could not load mesh for '{modelFilename}' because {e.Message}\n{e.StackTrace}");
+
+                foreach (GameObject bone in boneList.Where(bone => bone)) Object.Destroy(bone);
+
+                if (modelParent) Object.Destroy(modelParent);
+
+                modelParent = null;
+
+                return false;
+            }
+        }
+
+        private static bool ProcScriptBin(byte[] menuBuf, out ModelInfo modelInfo)
+        {
+            modelInfo = null;
+            using var binaryReader = new BinaryReader(new MemoryStream(menuBuf), Encoding.UTF8);
+
+            if (binaryReader.ReadString() != "CM3D2_MENU") return false;
+
+            modelInfo = new ModelInfo();
+
+            binaryReader.ReadInt32(); // file version
+            binaryReader.ReadString(); // txt path
+            binaryReader.ReadString(); // name
+            binaryReader.ReadString(); // category
+            binaryReader.ReadString(); // description
+            binaryReader.ReadInt32(); // idk (as long)
+
+            try
+            {
+                while (true)
+                {
+                    int numberOfProps = binaryReader.ReadByte();
+                    var menuPropString = string.Empty;
+
+                    if (numberOfProps != 0)
+                    {
+                        for (var i = 0; i < numberOfProps; i++)
+                        {
+                            menuPropString = $"{menuPropString}\"{binaryReader.ReadString()}\"";
+                        }
+
+                        if (menuPropString != string.Empty)
+                        {
+                            var header = UTY.GetStringCom(menuPropString);
+                            string[] menuProps = UTY.GetStringList(menuPropString);
+
+                            if (header == "end") break;
+
+                            switch (header)
+                            {
+                                case "マテリアル変更":
+                                {
+                                    var matNo = int.Parse(menuProps[2]);
+                                    var materialFile = menuProps[3];
+                                    modelInfo.MaterialChanges.Add(new MaterialChange(matNo, materialFile));
+                                    break;
+                                }
+                                case "additem":
+                                    modelInfo.ModelFile = menuProps[1];
+                                    break;
+                            }
+                        }
+                    }
+                    else
+                        break;
+                }
+            }
+            catch { return false; }
+
+            return true;
+        }
+
+        private static void ProcModScriptBin(byte[] cd, GameObject go)
+        {
+            var matDict = new Dictionary<string, byte[]>();
+            string modData;
+
+            using (var binaryReader = new BinaryReader(new MemoryStream(cd), Encoding.UTF8))
+            {
+                if (binaryReader.ReadString() != "CM3D2_MOD") return;
+
+                binaryReader.ReadInt32();
+                binaryReader.ReadString();
+                binaryReader.ReadString();
+                binaryReader.ReadString();
+                binaryReader.ReadString();
+                binaryReader.ReadString();
+                var mpnValue = binaryReader.ReadString();
+                var mpn = MPN.null_mpn;
+                try { mpn = (MPN) Enum.Parse(typeof(MPN), mpnValue, true); }
+                catch
+                {
+                    /* ignored */
+                }
+
+                if (mpn != MPN.null_mpn) binaryReader.ReadString();
+
+                modData = binaryReader.ReadString();
+                var entryCount = binaryReader.ReadInt32();
+                for (var i = 0; i < entryCount; i++)
+                {
+                    var key = binaryReader.ReadString();
+                    var count = binaryReader.ReadInt32();
+                    byte[] value = binaryReader.ReadBytes(count);
+                    matDict.Add(key, value);
+                }
+            }
+
+            var mode = IMode.None;
+            var materialChange = false;
+
+            Material material = null;
+            var materialIndex = 0;
+
+            using var stringReader = new StringReader(modData);
+
+            string line;
+
+            List<Renderer> renderers = null;
+
+            while ((line = stringReader.ReadLine()) != null)
+            {
+                string[] data = line.Split(new[] { '\t', ' ' }, StringSplitOptions.RemoveEmptyEntries);
+
+                switch (data[0])
+                {
+                    case "アイテム変更":
+                    case "マテリアル変更":
+                        mode = IMode.ItemChange;
+                        break;
+                    case "テクスチャ変更":
+                        mode = IMode.TexChange;
+                        break;
+                }
+
+                switch (mode)
+                {
+                    case IMode.ItemChange:
+                    {
+                        if (data[0] == "スロット名") materialChange = true;
+
+                        if (materialChange)
+                        {
+                            if (data[0] == "マテリアル番号")
+                            {
+                                materialIndex = int.Parse(data[1]);
+
+                                renderers ??= GetRenderers(go).ToList();
+
+                                foreach (Renderer renderer in renderers)
+                                {
+                                    if (materialIndex < renderer.materials.Length)
+                                        material = renderer.materials[materialIndex];
+                                }
+                            }
+
+                            if (!material) continue;
+
+                            switch (data[0])
+                            {
+                                case "テクスチャ設定":
+                                    ChangeTex(materialIndex, data[1], data[2].ToLower());
+                                    break;
+                                case "色設定":
+                                    material.SetColor(
+                                        data[1],
+                                        new Color(
+                                            float.Parse(data[2]) / 255f, float.Parse(data[3]) / 255f,
+                                            float.Parse(data[4]) / 255f, float.Parse(data[5]) / 255f
+                                        )
+                                    );
+                                    break;
+                                case "数値設定":
+                                    material.SetFloat(data[1], float.Parse(data[2]));
+                                    break;
+                            }
+                        }
+
+                        break;
+                    }
+                    case IMode.TexChange:
+                    {
+                        var matno = int.Parse(data[2]);
+                        ChangeTex(matno, data[3], data[4].ToLower());
+                        break;
+                    }
+                    case IMode.None: break;
+                    default: throw new ArgumentOutOfRangeException();
+                }
+            }
+
+            void ChangeTex(int matno, string prop, string filename)
+            {
+                byte[] buf = matDict[filename.ToLowerInvariant()];
+                var textureResource = new TextureResource(2, 2, TextureFormat.ARGB32, null, buf);
+                renderers ??= GetRenderers(go).ToList();
+                foreach (Renderer r in renderers)
+                {
+                    r.materials[matno].SetTexture(prop, null);
+                    Texture2D texture2D = textureResource.CreateTexture2D();
+                    texture2D.name = filename;
+                    r.materials[matno].SetTexture(prop, texture2D);
+                }
+            }
+        }
+
+        private class ModelInfo
+        {
+            public List<MaterialChange> MaterialChanges { get; } = new List<MaterialChange>();
+            public string ModelFile { get; set; }
+        }
+
+        private readonly struct MaterialChange
+        {
+            public int MaterialIndex { get; }
+            public string MaterialFile { get; }
+
+            public MaterialChange(int materialIndex, string materialFile)
+            {
+                MaterialIndex = materialIndex;
+                MaterialFile = materialFile;
+            }
+        }
+    }
+}

+ 85 - 0
src/MeidoPhotoStudio.Plugin/MyGui.cs

@@ -0,0 +1,85 @@
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public static class MpsGui
+    {
+        public static readonly GUILayoutOption HalfSlider = GUILayout.Width(98);
+        public static readonly Texture2D white = Utility.MakeTex(2, 2, Color.white);
+        public static readonly Texture2D transparentBlack = Utility.MakeTex(2, 2, new Color(0f, 0f, 0f, 0.8f));
+        public static readonly GUIStyle SliderLabelStyle;
+        public static readonly GUIStyle SliderStyle;
+        public static readonly GUIStyle SliderStyleNoLabel;
+        public static readonly GUIStyle SliderTextBoxStyle;
+        public static readonly GUIStyle SliderThumbStyle;
+        public static readonly GUIStyle SliderResetButtonStyle;
+        private static readonly GUIStyle lineStyleWhite;
+        private static readonly GUIStyle lineStyleBlack;
+        private static readonly GUIStyle textureBoxStyle;
+        private static readonly GUIStyle headerLabelStyle;
+
+        static MpsGui()
+        {
+            GUI.skin = null;
+
+            lineStyleWhite = new GUIStyle(GUI.skin.box)
+            {
+                margin = new RectOffset(0, 0, 8, 8),
+                normal = { background = Utility.MakeTex(2, 2, new Color(1f, 1f, 1f, 0.2f)) }
+            };
+            lineStyleWhite.padding = lineStyleWhite.border = new RectOffset(0, 0, 1, 1);
+
+            lineStyleBlack = new GUIStyle(lineStyleWhite)
+            {
+                normal = { background = Utility.MakeTex(2, 2, new Color(0f, 0f, 0f, 0.3f)) }
+            };
+
+            textureBoxStyle = new GUIStyle(GUI.skin.box)
+            {
+                normal = { background = Utility.MakeTex(2, 2, new Color(0f, 0f, 0f, 0f)) }
+            };
+            textureBoxStyle.padding = textureBoxStyle.margin = new RectOffset(0, 0, 0, 0);
+
+            headerLabelStyle = new GUIStyle(GUI.skin.label)
+            {
+                padding = new RectOffset(7, 0, 0, -5),
+                normal = { textColor = Color.white },
+                fontSize = 14
+            };
+
+            SliderLabelStyle = new GUIStyle(GUI.skin.label)
+            {
+                alignment = TextAnchor.LowerLeft,
+                fontSize = 13,
+                normal = { textColor = Color.white }
+            };
+            SliderStyle = new GUIStyle(GUI.skin.horizontalSlider);
+            SliderStyleNoLabel = new GUIStyle(SliderStyle) { margin = { top = 10 } };
+            SliderTextBoxStyle = new GUIStyle(GUI.skin.textField) { fontSize = 12, };
+            SliderResetButtonStyle = new GUIStyle(GUI.skin.button)
+            {
+                alignment = TextAnchor.MiddleRight,
+                fontSize = 10
+            };
+            SliderThumbStyle = new GUIStyle(GUI.skin.horizontalSliderThumb);
+        }
+
+        private static void Line(GUIStyle style) => GUILayout.Box(GUIContent.none, style, GUILayout.Height(1));
+
+        public static void WhiteLine() => Line(lineStyleWhite);
+
+        public static void BlackLine() => Line(lineStyleBlack);
+
+        public static void DrawTexture(Texture texture, params GUILayoutOption[] layoutOptions)
+        {
+            GUILayout.Box(texture, textureBoxStyle, layoutOptions);
+        }
+
+        public static int ClampFont(int size, int min, int max) => Mathf.Clamp(Utility.GetPix(size), min, max);
+
+        public static void Header(string text, params GUILayoutOption[] layoutOptions)
+        {
+            GUILayout.Label(text, headerLabelStyle, layoutOptions);
+        }
+    }
+}

+ 24 - 0
src/MeidoPhotoStudio.Plugin/Patchers/AllProcPropSeqStartPatcher.cs

@@ -0,0 +1,24 @@
+using System;
+using HarmonyLib;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    // TODO: Extend this further to potentially reduce the need for coroutines that wait for maid proc state
+    public static class AllProcPropSeqStartPatcher
+    {
+        public static event EventHandler<ProcStartEventArgs> SequenceStart;
+
+        [HarmonyPatch(typeof(Maid), nameof(Maid.AllProcPropSeqStart))]
+        [HarmonyPostfix]
+        private static void NotifyProcStart(Maid __instance)
+        {
+            SequenceStart?.Invoke(null, new ProcStartEventArgs(__instance));
+        }
+    }
+
+    public class ProcStartEventArgs : EventArgs
+    {
+        public readonly Maid maid;
+        public ProcStartEventArgs(Maid maid) => this.maid = maid;
+    }
+}

+ 21 - 0
src/MeidoPhotoStudio.Plugin/Patchers/BgMgrPatcher.cs

@@ -0,0 +1,21 @@
+using System;
+using HarmonyLib;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public static class BgMgrPatcher
+    {
+        public static event EventHandler ChangeBgBegin;
+        public static event EventHandler ChangeBgEnd;
+
+        [HarmonyPatch(typeof(BgMgr), nameof(BgMgr.ChangeBg))]
+        [HarmonyPatch(typeof(BgMgr), nameof(BgMgr.ChangeBgMyRoom))]
+        [HarmonyPrefix]
+        private static void NotifyBeginChangeBg() => ChangeBgBegin?.Invoke(null, EventArgs.Empty);
+
+        [HarmonyPatch(typeof(BgMgr), nameof(BgMgr.ChangeBg))]
+        [HarmonyPatch(typeof(BgMgr), nameof(BgMgr.ChangeBgMyRoom))]
+        [HarmonyPostfix]
+        private static void NotifyEndChangeBg() => ChangeBgEnd?.Invoke(null, EventArgs.Empty);
+    }
+}

+ 10 - 0
src/MeidoPhotoStudio.Plugin/Serialization/ISerializer.cs

@@ -0,0 +1,10 @@
+using System.IO;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public interface ISerializer
+    {
+        void Serialize(object thing, BinaryWriter writer);
+        void Deserialize(object thing, BinaryReader reader, SceneMetadata metadata);
+    }
+}

+ 10 - 0
src/MeidoPhotoStudio.Plugin/Serialization/ISimpleSerializer.cs

@@ -0,0 +1,10 @@
+using System.IO;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public interface ISimpleSerializer
+    {
+        void Serialize(object obj, BinaryWriter writer);
+        object Deserialize(BinaryReader reader, SceneMetadata metadata);
+    }
+}

+ 41 - 0
src/MeidoPhotoStudio.Plugin/Serialization/SceneMetadata.cs

@@ -0,0 +1,41 @@
+using System.IO;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class SceneMetadata
+    {
+        public short Version { get; init; }
+        public bool Environment { get; init; }
+        public int MaidCount { get; init; }
+        public bool MMConverted { get; init; }
+
+        public void WriteMetadata(BinaryWriter writer)
+        {
+            writer.Write(Version);
+            writer.Write(Environment);
+            writer.Write(MaidCount);
+            writer.Write(MMConverted);
+        }
+
+        public static SceneMetadata ReadMetadata(BinaryReader reader)
+        {
+            return new()
+            {
+                Version = reader.ReadVersion(),
+                Environment = reader.ReadBoolean(),
+                MaidCount = reader.ReadInt32(),
+                MMConverted = reader.ReadBoolean()
+            };
+        }
+
+        public void Deconstruct(
+            out short version, out bool environment, out int maidCount, out bool mmConverted
+        )
+        {
+            version = Version;
+            environment = Environment;
+            mmConverted = MMConverted;
+            maidCount = MaidCount;
+        }
+    }
+}

+ 42 - 0
src/MeidoPhotoStudio.Plugin/Serialization/Serialization.cs

@@ -0,0 +1,42 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public static class Serialization
+    {
+        private static readonly Dictionary<Type, ISerializer> Serializers;
+
+        private static readonly Dictionary<Type, ISimpleSerializer> SimpleSerializers;
+
+        static Serialization()
+        {
+            var types = (from t in typeof(MeidoPhotoStudio).Assembly.GetTypes()
+                let baseType = t.BaseType
+                where !t.IsAbstract && !t.IsInterface && baseType?.IsGenericType == true
+                select new { type = t, baseType }).ToArray();
+
+            Serializers = types.Where(t => t.baseType.GetGenericTypeDefinition() == typeof(Serializer<>))
+                .Select(t => new {t.type, arg = t.baseType.GetGenericArguments()[0]})
+                .ToDictionary(x => x.arg, x => (ISerializer)Activator.CreateInstance(x.type));
+
+            SimpleSerializers = types.Where(t => t.baseType.GetGenericTypeDefinition() == typeof(SimpleSerializer<>))
+                .Select(t => new {t.type, arg = t.baseType.GetGenericArguments()[0]})
+                .ToDictionary(x => x.arg, x => (ISimpleSerializer)Activator.CreateInstance(x.type));
+        }
+
+        public static Serializer<T> Get<T>() => Serializers[typeof(T)] as Serializer<T>;
+
+        public static ISerializer Get(Type type) => Serializers[type];
+
+        public static SimpleSerializer<T> GetSimple<T>() => SimpleSerializers[typeof(T)] as SimpleSerializer<T>;
+
+        public static ISimpleSerializer GetSimple(Type type) => SimpleSerializers[type];
+
+        public static short ReadVersion(this BinaryReader reader) => reader.ReadInt16();
+
+        public static void WriteVersion(this BinaryWriter writer, short version) => writer.Write(version);
+    }
+}

+ 15 - 0
src/MeidoPhotoStudio.Plugin/Serialization/Serializer.cs

@@ -0,0 +1,15 @@
+using System.IO;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public abstract class Serializer<T> : ISerializer
+    {
+        void ISerializer.Serialize(object obj, BinaryWriter writer) => Serialize((T) obj, writer);
+
+        void ISerializer.Deserialize(object obj, BinaryReader reader, SceneMetadata metadata)
+            => Deserialize((T) obj, reader, metadata);
+
+        public abstract void Serialize(T obj, BinaryWriter writer);
+        public abstract void Deserialize(T obj, BinaryReader reader, SceneMetadata metadata);
+    }
+}

+ 27 - 0
src/MeidoPhotoStudio.Plugin/Serialization/Serializers/AttachPointInfoSerializer.cs

@@ -0,0 +1,27 @@
+using System.IO;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class AttachPointInfoSerializer : SimpleSerializer<AttachPointInfo>
+    {
+        private const short version = 1;
+
+        public override void Serialize(AttachPointInfo info, BinaryWriter writer)
+        {
+            writer.WriteVersion(version);
+
+            writer.Write((int) info.AttachPoint);
+            writer.Write(info.MaidIndex);
+        }
+
+        public override AttachPointInfo Deserialize(BinaryReader reader, SceneMetadata metadata)
+        {
+            _ = reader.ReadVersion();
+
+            var attachPoint = (AttachPoint) reader.ReadInt32();
+            var maidIndex = reader.ReadInt32();
+
+            return new AttachPointInfo(attachPoint, string.Empty, maidIndex);
+        }
+    }
+}

+ 29 - 0
src/MeidoPhotoStudio.Plugin/Serialization/Serializers/CameraInfoSerializer.cs

@@ -0,0 +1,29 @@
+using System.IO;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class CameraInfoSerializer : Serializer<CameraInfo>
+    {
+        private const short version = 1;
+
+        public override void Serialize(CameraInfo info, BinaryWriter writer)
+        {
+            writer.WriteVersion(version);
+
+            writer.Write(info.TargetPos);
+            writer.Write(info.Angle);
+            writer.Write(info.Distance);
+            writer.Write(info.FOV);
+        }
+
+        public override void Deserialize(CameraInfo info, BinaryReader reader, SceneMetadata metadata)
+        {
+            _ = reader.ReadVersion();
+
+            info.TargetPos = reader.ReadVector3();
+            info.Angle = reader.ReadQuaternion();
+            info.Distance = reader.ReadSingle();
+            info.FOV = reader.ReadSingle();
+        }
+    }
+}

+ 41 - 0
src/MeidoPhotoStudio.Plugin/Serialization/Serializers/DragPointLightSerializer.cs

@@ -0,0 +1,41 @@
+using System.IO;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class DragPointLightSerializer : Serializer<DragPointLight>
+    {
+        private const short version = 1;
+        private static Serializer<LightProperty> LightPropertySerializer => Serialization.Get<LightProperty>();
+
+        public override void Serialize(DragPointLight light, BinaryWriter writer)
+        {
+            writer.WriteVersion(version);
+
+            LightProperty[] lightList = GetLightProperties(light);
+
+            for (var i = 0; i < 3; i++) LightPropertySerializer.Serialize(lightList[i], writer);
+
+            writer.Write(light.MyObject.position);
+            writer.Write((int) light.SelectedLightType);
+            writer.Write(light.IsColourMode);
+            writer.Write(light.IsDisabled);
+        }
+
+        public override void Deserialize(DragPointLight light, BinaryReader reader, SceneMetadata metadata)
+        {
+            _ = reader.ReadVersion();
+
+            LightProperty[] lightList = GetLightProperties(light);
+            
+            for (var i = 0; i < 3; i++) LightPropertySerializer.Deserialize(lightList[i], reader, metadata);
+
+            light.MyObject.position = reader.ReadVector3();
+            light.SetLightType((DragPointLight.MPSLightType) reader.ReadInt32());
+            light.IsColourMode = reader.ReadBoolean();
+            light.IsDisabled = reader.ReadBoolean();
+        }
+
+        private static LightProperty[] GetLightProperties(DragPointLight light)
+            => Utility.GetFieldValue<DragPointLight, LightProperty[]>(light, "LightProperties");
+    }
+}

+ 34 - 0
src/MeidoPhotoStudio.Plugin/Serialization/Serializers/EffectSerializers/BloomEffectSerializer.cs

@@ -0,0 +1,34 @@
+using System.IO;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class BloomEffectSerializer : Serializer<BloomEffectManager>
+    {
+        private const short version = 1;
+
+        public override void Serialize(BloomEffectManager effect, BinaryWriter writer)
+        {
+            writer.Write(BloomEffectManager.header);
+            writer.WriteVersion(version);
+
+            writer.Write(effect.Active);
+            writer.Write(effect.BloomValue);
+            writer.Write(effect.BlurIterations);
+            writer.Write(effect.BloomThresholdColour);
+            writer.Write(effect.BloomHDR);
+        }
+
+        public override void Deserialize(BloomEffectManager effect, BinaryReader reader, SceneMetadata metadata)
+        {
+            _ = reader.ReadVersion();
+
+            var active = reader.ReadBoolean();
+            effect.BloomValue = reader.ReadSingle();
+            effect.BlurIterations = reader.ReadInt32();
+            effect.BloomThresholdColour = reader.ReadColour();
+            effect.BloomHDR = reader.ReadBoolean();
+
+            effect.SetEffectActive(active);
+        }
+    }
+}

+ 28 - 0
src/MeidoPhotoStudio.Plugin/Serialization/Serializers/EffectSerializers/BlurEffectSerializer.cs

@@ -0,0 +1,28 @@
+using System.IO;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class BlurEffectSerializer : Serializer<BlurEffectManager>
+    {
+        private const short version = 1;
+
+        public override void Serialize(BlurEffectManager effect, BinaryWriter writer)
+        {
+            writer.Write(BlurEffectManager.header);
+            writer.WriteVersion(version);
+
+            writer.Write(effect.Active);
+            writer.Write(effect.BlurSize);
+        }
+
+        public override void Deserialize(BlurEffectManager effect, BinaryReader reader, SceneMetadata metadata)
+        {
+            _ = reader.ReadVersion();
+
+            var active = reader.ReadBoolean();
+            effect.BlurSize = reader.ReadSingle();
+
+            effect.SetEffectActive(active);
+        }
+    }
+}

+ 36 - 0
src/MeidoPhotoStudio.Plugin/Serialization/Serializers/EffectSerializers/DepthOfFieldEffectSerializer.cs

@@ -0,0 +1,36 @@
+using System.IO;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class DepthOfFieldEffectSerializer : Serializer<DepthOfFieldEffectManager>
+    {
+        private const short version = 1;
+
+        public override void Serialize(DepthOfFieldEffectManager effect, BinaryWriter writer)
+        {
+            writer.Write(DepthOfFieldEffectManager.header);
+            writer.WriteVersion(version);
+
+            writer.Write(effect.Active);
+            writer.Write(effect.FocalLength);
+            writer.Write(effect.FocalSize);
+            writer.Write(effect.Aperture);
+            writer.Write(effect.MaxBlurSize);
+            writer.Write(effect.VisualizeFocus);
+        }
+
+        public override void Deserialize(DepthOfFieldEffectManager effect, BinaryReader reader, SceneMetadata metadata)
+        {
+            _ = reader.ReadVersion();
+
+            var active = reader.ReadBoolean();
+            effect.FocalLength = reader.ReadSingle();
+            effect.FocalSize = reader.ReadSingle();
+            effect.Aperture = reader.ReadSingle();
+            effect.MaxBlurSize = reader.ReadSingle();
+            effect.VisualizeFocus = reader.ReadBoolean();
+
+            effect.SetEffectActive(active);
+        }
+    }
+}

+ 36 - 0
src/MeidoPhotoStudio.Plugin/Serialization/Serializers/EffectSerializers/FogEffectSerializer.cs

@@ -0,0 +1,36 @@
+using System.IO;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class FogEffectSerializer : Serializer<FogEffectManager>
+    {
+        private const short version = 1;
+
+        public override void Serialize(FogEffectManager effect, BinaryWriter writer)
+        {
+            writer.Write(FogEffectManager.header);
+            writer.WriteVersion(version);
+
+            writer.Write(effect.Active);
+            writer.Write(effect.Distance);
+            writer.Write(effect.Density);
+            writer.Write(effect.HeightScale);
+            writer.Write(effect.Height);
+            writer.WriteColour(effect.FogColour);
+        }
+
+        public override void Deserialize(FogEffectManager effect, BinaryReader reader, SceneMetadata metadata)
+        {
+            _ = reader.ReadVersion();
+
+            var active = reader.ReadBoolean();
+            effect.Distance = reader.ReadSingle();
+            effect.Density = reader.ReadSingle();
+            effect.HeightScale = reader.ReadSingle();
+            effect.Height = reader.ReadSingle();
+            effect.FogColour = reader.ReadColour();
+
+            effect.SetEffectActive(active);
+        }
+    }
+}

+ 24 - 0
src/MeidoPhotoStudio.Plugin/Serialization/Serializers/EffectSerializers/SepiaToneEffectSerializer.cs

@@ -0,0 +1,24 @@
+using System.IO;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class SepiaToneEffectSerializer : Serializer<SepiaToneEffectManger>
+    {
+        private const short version = 1;
+
+        public override void Serialize(SepiaToneEffectManger effect, BinaryWriter writer)
+        {
+            writer.Write(SepiaToneEffectManger.header);
+            writer.WriteVersion(version);
+
+            writer.Write(effect.Active);
+        }
+
+        public override void Deserialize(SepiaToneEffectManger effect, BinaryReader reader, SceneMetadata metadata)
+        {
+            _ = reader.ReadVersion();
+
+            effect.SetEffectActive(reader.ReadBoolean());
+        }
+    }
+}

+ 34 - 0
src/MeidoPhotoStudio.Plugin/Serialization/Serializers/EffectSerializers/VignetteEffectSerializer.cs

@@ -0,0 +1,34 @@
+using System.IO;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class VignetteEffectSerializer : Serializer<VignetteEffectManager>
+    {
+        private const short version = 1;
+
+        public override void Serialize(VignetteEffectManager manager, BinaryWriter writer)
+        {
+            writer.Write(VignetteEffectManager.header);
+            writer.WriteVersion(version);
+
+            writer.Write(manager.Active);
+            writer.Write(manager.Intensity);
+            writer.Write(manager.Blur);
+            writer.Write(manager.BlurSpread);
+            writer.Write(manager.ChromaticAberration);
+        }
+
+        public override void Deserialize(VignetteEffectManager manager, BinaryReader reader, SceneMetadata metadata)
+        {
+            _ = reader.ReadVersion();
+
+            var active = reader.ReadBoolean();
+            manager.Intensity = reader.ReadSingle();
+            manager.Blur = reader.ReadSingle();
+            manager.BlurSpread = reader.ReadSingle();
+            manager.ChromaticAberration = reader.ReadSingle();
+
+            manager.SetEffectActive(active);
+        }
+    }
+}

+ 33 - 0
src/MeidoPhotoStudio.Plugin/Serialization/Serializers/LightPropertySerializer.cs

@@ -0,0 +1,33 @@
+using System.IO;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class LightPropertySerializer : Serializer<LightProperty>
+    {
+        private const short version = 1;
+
+        public override void Serialize(LightProperty prop, BinaryWriter writer)
+        {
+            writer.WriteVersion(version);
+
+            writer.Write(prop.Rotation);
+            writer.Write(prop.Intensity);
+            writer.Write(prop.Range);
+            writer.Write(prop.SpotAngle);
+            writer.Write(prop.ShadowStrength);
+            writer.Write(prop.LightColour);
+        }
+
+        public override void Deserialize(LightProperty prop, BinaryReader reader, SceneMetadata metadata)
+        {
+            _ = reader.ReadVersion();
+
+            prop.Rotation = reader.ReadQuaternion();
+            prop.Intensity = reader.ReadSingle();
+            prop.Range = reader.ReadSingle();
+            prop.SpotAngle = reader.ReadSingle();
+            prop.ShadowStrength = reader.ReadSingle();
+            prop.LightColour = reader.ReadColour();
+        }
+    }
+}

+ 46 - 0
src/MeidoPhotoStudio.Plugin/Serialization/Serializers/ManagerSerializers/CameraManagerSerializer.cs

@@ -0,0 +1,46 @@
+using System.IO;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class CameraManagerSerializer : Serializer<CameraManager>
+    {
+        private const short version = 1;
+        private static Serializer<CameraInfo> InfoSerializer => Serialization.Get<CameraInfo>();
+        private static readonly CameraInfo dummyInfo = new();
+
+        public override void Serialize(CameraManager manager, BinaryWriter writer)
+        {
+            writer.Write(CameraManager.header);
+            writer.WriteVersion(version);
+
+            CameraInfo[] cameraInfos = GetCameraInfos(manager);
+            cameraInfos[manager.CurrentCameraIndex].UpdateInfo(CameraUtility.MainCamera);
+
+            writer.Write(manager.CurrentCameraIndex);
+            writer.Write(manager.CameraCount);
+            foreach (var info in cameraInfos) InfoSerializer.Serialize(info, writer);
+        }
+
+        public override void Deserialize(CameraManager manager, BinaryReader reader, SceneMetadata metadata)
+        {
+            _ = reader.ReadVersion();
+
+            var camera = CameraUtility.MainCamera;
+
+            manager.CurrentCameraIndex = reader.ReadInt32();
+
+            var cameraCount = reader.ReadInt32();
+
+            CameraInfo[] cameraInfos = GetCameraInfos(manager);
+            for (var i = 0; i < cameraCount; i++)
+                InfoSerializer.Deserialize(i >= manager.CameraCount ? dummyInfo : cameraInfos[i], reader, metadata);
+
+            if (metadata.Environment) return;
+
+            cameraInfos[manager.CurrentCameraIndex].Apply(camera);
+        }
+
+        private static CameraInfo[] GetCameraInfos(CameraManager manager)
+            => Utility.GetFieldValue<CameraManager, CameraInfo[]>(manager, "cameraInfos");
+    }
+}

+ 43 - 0
src/MeidoPhotoStudio.Plugin/Serialization/Serializers/ManagerSerializers/EffectManagerSerializer.cs

@@ -0,0 +1,43 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class EffectManagerSerializer : Serializer<EffectManager>
+    {
+        private const short version = 1;
+
+        public override void Serialize(EffectManager manager, BinaryWriter writer)
+        {
+            writer.Write(EffectManager.header);
+            writer.WriteVersion(version);
+
+            foreach (var effectManager in GetEffectManagers(manager).Values)
+                Serialization.Get(effectManager.GetType()).Serialize(effectManager, writer);
+
+            writer.Write(EffectManager.footer);
+        }
+
+        public override void Deserialize(EffectManager manager, BinaryReader reader, SceneMetadata metadata)
+        {
+            _ = reader.ReadVersion();
+
+            Dictionary<string, IEffectManager> headerToManager = GetEffectManagers(manager).ToDictionary(
+                x => (string) x.Key.GetField("header").GetValue(null),
+                y => y.Value
+            );
+
+            string header;
+            while ((header = reader.ReadString()) != EffectManager.footer)
+            {
+                var effectManager = headerToManager[header];
+                Serialization.Get(effectManager.GetType()).Deserialize(effectManager, reader, metadata);
+            }
+        }
+
+        private static Dictionary<Type, IEffectManager> GetEffectManagers(EffectManager manager)
+            => Utility.GetFieldValue<EffectManager, Dictionary<Type, IEffectManager>>(manager, "EffectManagers");
+    }
+}

+ 71 - 0
src/MeidoPhotoStudio.Plugin/Serialization/Serializers/ManagerSerializers/EnvironmentManagerSerializer.cs

@@ -0,0 +1,71 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class EnvironmentManagerSerializer : Serializer<EnvironmentManager>
+    {
+        private const short version = 1;
+
+        private static SimpleSerializer<TransformDTO> TransformDtoSerializer => Serialization.GetSimple<TransformDTO>();
+
+        public override void Serialize(EnvironmentManager manager, BinaryWriter writer)
+        {
+            writer.Write(EnvironmentManager.header);
+            writer.WriteVersion(version);
+
+            writer.Write(manager.CurrentBgAsset);
+
+            var bgTransform = GetBgTransform(manager);
+            var transformDto = bgTransform ? new TransformDTO(bgTransform) : new TransformDTO();
+
+            TransformDtoSerializer.Serialize(transformDto, writer);
+        }
+
+        public override void Deserialize(EnvironmentManager manager, BinaryReader reader, SceneMetadata metadata)
+        {
+            _ = reader.ReadVersion();
+
+            var bgAsset = reader.ReadString();
+
+            var transformDto = TransformDtoSerializer.Deserialize(reader, metadata);
+
+            var creativeBg = Utility.IsGuidString(bgAsset);
+
+            List<string> bgList = creativeBg
+                ? Constants.MyRoomCustomBGList.ConvertAll(kvp => kvp.Key)
+                : Constants.BGList;
+
+            var assetIndex = bgList.FindIndex(
+                asset => asset.Equals(bgAsset, StringComparison.InvariantCultureIgnoreCase)
+            );
+
+            var validBg = assetIndex >= 0;
+
+            if (validBg) bgAsset = bgList[assetIndex];
+            else
+            {
+                Utility.LogWarning($"Could not load BG '{bgAsset}'");
+                creativeBg = false;
+                bgAsset = EnvironmentManager.defaultBg;
+            }
+
+            manager.ChangeBackground(bgAsset, creativeBg);
+
+            if (!validBg) return;
+
+            var bg = GetBgTransform(manager);
+
+            if (!bg) return;
+
+            bg.position = transformDto.Position;
+            bg.rotation = transformDto.Rotation;
+            bg.localScale = transformDto.LocalScale;
+        }
+
+        private static Transform GetBgTransform(EnvironmentManager manager)
+            => Utility.GetFieldValue<EnvironmentManager, Transform>(manager, "bg");
+    }
+}

+ 43 - 0
src/MeidoPhotoStudio.Plugin/Serialization/Serializers/ManagerSerializers/LightManagerSerializer.cs

@@ -0,0 +1,43 @@
+using System.Collections.Generic;
+using System.IO;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class LightManagerSerializer : Serializer<LightManager>
+    {
+        private const short version = 1;
+        private static Serializer<DragPointLight> LightSerializer => Serialization.Get<DragPointLight>();
+
+        public override void Serialize(LightManager manager, BinaryWriter writer)
+        {
+            writer.Write(LightManager.header);
+            writer.WriteVersion(version);
+
+            List<DragPointLight> list = GetLightList(manager);
+            writer.Write(list.Count);
+            foreach (var light in list) LightSerializer.Serialize(light, writer);
+        }
+
+        public override void Deserialize(LightManager manager, BinaryReader reader, SceneMetadata metadata)
+        {
+            manager.ClearLights();
+
+            _ = reader.ReadVersion();
+
+            var lightCount = reader.ReadInt32();
+
+            List<DragPointLight> list = GetLightList(manager);
+            
+            
+            LightSerializer.Deserialize(list[0], reader, metadata);
+            for (var i = 1; i < lightCount; i++)
+            {
+                manager.AddLight();
+                LightSerializer.Deserialize(list[i], reader, metadata);
+            }
+        }
+
+        private static List<DragPointLight> GetLightList(LightManager manager)
+            => Utility.GetFieldValue<LightManager, List<DragPointLight>>(manager, "lightList");
+    }
+}

+ 86 - 0
src/MeidoPhotoStudio.Plugin/Serialization/Serializers/ManagerSerializers/MeidoManagerSerializer.cs

@@ -0,0 +1,86 @@
+using System.Collections.Generic;
+using System.IO;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class MeidoManagerSerializer : Serializer<MeidoManager>
+    {
+        private const short version = 1;
+        private static Serializer<Meido> MeidoSerializer => Serialization.Get<Meido>();
+
+        public override void Serialize(MeidoManager manager, BinaryWriter writer)
+        {
+            writer.Write(MeidoManager.header);
+            writer.WriteVersion(version);
+
+            List<Meido> meidoList = manager.ActiveMeidoList;
+
+            var meidoCount = meidoList.Count;
+
+            var hairPosition = Vector3.zero;
+            var skirtPosition = Vector3.zero;
+
+            var hairMeidoFound = false;
+            var skirtMeidoFound = false;
+
+            var globalGravity = manager.GlobalGravity;
+
+            writer.Write(meidoCount);
+            foreach (var meido in meidoList)
+            {
+                MeidoSerializer.Serialize(meido, writer);
+
+                if (!globalGravity || meidoCount <= 0) continue;
+
+                // Get gravity and skirt control positions to apply to meidos past the meido count
+                if (!hairMeidoFound && meido.HairGravityControl.Valid)
+                {
+                    hairPosition = meido.HairGravityControl.Control.transform.localPosition;
+                    hairMeidoFound = true;
+                }
+                else if (!skirtMeidoFound && meido.SkirtGravityControl.Valid)
+                {
+                    skirtPosition = meido.SkirtGravityControl.Control.transform.localPosition;
+                    skirtMeidoFound = true;
+                }
+            }
+
+            writer.Write(globalGravity);
+            writer.Write(hairPosition);
+            writer.Write(skirtPosition);
+        }
+
+        public override void Deserialize(MeidoManager manager, BinaryReader reader, SceneMetadata metadata)
+        {
+            _ = reader.ReadVersion();
+
+            var meidoCount = reader.ReadInt32();
+            for (var i = 0; i < meidoCount; i++)
+            {
+                if (i >= manager.ActiveMeidoList.Count)
+                {
+                    reader.BaseStream.Seek(reader.ReadInt64(), SeekOrigin.Current);
+                    continue;
+                }
+
+                MeidoSerializer.Deserialize(manager.ActiveMeidoList[i], reader, metadata);
+            }
+
+            var globalGravity = reader.ReadBoolean();
+            var hairPosition = reader.ReadVector3();
+            var skirtPosition = reader.ReadVector3();
+            Utility.SetFieldValue(manager, "globalGravity", globalGravity);
+
+            if (!globalGravity) return;
+
+            foreach (var meido in manager.ActiveMeidoList)
+            {
+                meido.HairGravityActive = true;
+                meido.SkirtGravityActive = true;
+                meido.ApplyGravity(hairPosition);
+                meido.ApplyGravity(skirtPosition, true);
+            }
+        }
+    }
+}

+ 33 - 0
src/MeidoPhotoStudio.Plugin/Serialization/Serializers/ManagerSerializers/MessageWindowManagerSerializer.cs

@@ -0,0 +1,33 @@
+using System.IO;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class MessageWindowManagerSerializer : Serializer<MessageWindowManager>
+    {
+        private const short version = 1;
+
+        public override void Serialize(MessageWindowManager manager, BinaryWriter writer)
+        {
+            writer.Write(MessageWindowManager.header);
+            writer.WriteVersion(version);
+
+            writer.Write(manager.ShowingMessage);
+            writer.Write(manager.FontSize);
+            writer.Write(manager.MessageName);
+            writer.Write(manager.MessageText);
+        }
+
+        public override void Deserialize(MessageWindowManager manager, BinaryReader reader, SceneMetadata metadata)
+        {
+            manager.CloseMessagePanel();
+
+            _ = reader.ReadVersion();
+
+            var showingMessage = reader.ReadBoolean();
+            manager.FontSize = reader.ReadInt32();
+            var messageName = reader.ReadString();
+            var messageText = reader.ReadString();
+            if (showingMessage) manager.ShowMessage(messageName, messageText);
+        }
+    }
+}

+ 69 - 0
src/MeidoPhotoStudio.Plugin/Serialization/Serializers/ManagerSerializers/PropManagerSerializer.cs

@@ -0,0 +1,69 @@
+using System.Collections.Generic;
+using System.IO;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class PropManagerSerializer : Serializer<PropManager>
+    {
+        private const short version = 1;
+
+        private static SimpleSerializer<DragPointPropDTO> DragPointDtoSerializer
+            => Serialization.GetSimple<DragPointPropDTO>();
+
+        public override void Serialize(PropManager manager, BinaryWriter writer)
+        {
+            writer.Write(PropManager.header);
+            writer.WriteVersion(version);
+
+            List<DragPointProp> propList = GetPropList(manager);
+
+            writer.Write(propList.Count);
+            foreach (var prop in propList) DragPointDtoSerializer.Serialize(new DragPointPropDTO(prop), writer);
+        }
+
+        public override void Deserialize(PropManager manager, BinaryReader reader, SceneMetadata metadata)
+        {
+            _ = reader.ReadVersion();
+
+            manager.DeleteAllProps();
+
+            List<DragPointProp> propList = GetPropList(manager);
+
+            var propCount = reader.ReadInt32();
+            var propIndex = 0;
+            for (var i = 0; i < propCount; i++)
+            {
+                var dragPointPropDto = DragPointDtoSerializer.Deserialize(reader, metadata);
+
+                if (!manager.AddFromPropInfo(dragPointPropDto.PropInfo)) continue;
+
+                Apply(manager, propList[propIndex], dragPointPropDto);
+
+                propIndex++;
+            }
+        }
+
+        private static void Apply(PropManager manager, DragPointProp prop, DragPointPropDTO dto)
+        {
+            var (transformDto, attachPointInfo, shadowCasting) = dto;
+
+            prop.ShadowCasting = shadowCasting;
+
+            var transform = prop.MyObject;
+
+            if (attachPointInfo.AttachPoint != AttachPoint.None)
+            {
+                manager.AttachProp(prop, attachPointInfo.AttachPoint, attachPointInfo.MaidIndex);
+                transform.localPosition = transformDto.LocalPosition;
+                transform.localRotation = transformDto.LocalRotation;
+            }
+
+            transform.position = transformDto.Position;
+            transform.rotation = transformDto.Rotation;
+            transform.localScale = transformDto.LocalScale;
+        }
+
+        private static List<DragPointProp> GetPropList(PropManager manager)
+            => Utility.GetFieldValue<PropManager, List<DragPointProp>>(manager, "propList");
+    }
+}

+ 277 - 0
src/MeidoPhotoStudio.Plugin/Serialization/Serializers/MeidoSerializer.cs

@@ -0,0 +1,277 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class MeidoSerializer : Serializer<Meido>
+    {
+        private const short version = 1;
+        private const short headVersion = 1;
+        private const short bodyVersion = 1;
+        private const short clothingVersion = 1;
+
+        private static SimpleSerializer<PoseInfo> PoseInfoSerializer => Serialization.GetSimple<PoseInfo>();
+
+        private static SimpleSerializer<TransformDTO> TransformDtoSerializer => Serialization.GetSimple<TransformDTO>();
+
+        public override void Serialize(Meido meido, BinaryWriter writer)
+        {
+            var maid = meido.Maid;
+
+            using var memoryStream = new MemoryStream();
+            using var tempWriter = new BinaryWriter(memoryStream, Encoding.UTF8);
+
+            tempWriter.WriteVersion(version);
+
+            TransformDtoSerializer.Serialize(new TransformDTO(maid.transform), tempWriter);
+
+            SerializeHead(meido, tempWriter);
+
+            SerializeBody(meido, tempWriter);
+
+            SerializeClothing(meido, tempWriter);
+
+            writer.Write(memoryStream.Length);
+            writer.Write(memoryStream.ToArray());
+        }
+
+        public override void Deserialize(Meido meido, BinaryReader reader, SceneMetadata metadata)
+        {
+            var maid = meido.Maid;
+
+            maid.GetAnimation().Stop();
+            meido.DetachAllMpnAttach();
+            meido.StopBlink();
+
+            reader.ReadInt64(); // data length
+
+            _ = reader.ReadVersion();
+
+            var transformDto = TransformDtoSerializer.Deserialize(reader, metadata);
+            var maidTransform = maid.transform;
+            maidTransform.position = transformDto.Position;
+            maidTransform.rotation = transformDto.Rotation;
+            maidTransform.localScale = transformDto.LocalScale;
+
+            meido.IKManager.SetDragPointScale(maidTransform.localScale.x);
+
+            DeserializeHead(meido, reader, metadata);
+
+            DeserializeBody(meido, reader, metadata);
+
+            DeserializeClothing(meido, reader, metadata);
+        }
+
+        private static void SerializeHead(Meido meido, BinaryWriter writer)
+        {
+            var body = meido.Body;
+
+            writer.WriteVersion(headVersion);
+
+            // eye direction
+            writer.WriteQuaternion(body.quaDefEyeL * Quaternion.Inverse(meido.DefaultEyeRotL));
+            writer.WriteQuaternion(body.quaDefEyeR * Quaternion.Inverse(meido.DefaultEyeRotR));
+
+            // free look
+            writer.Write(meido.FreeLook);
+            writer.WriteVector3(body.offsetLookTarget);
+            writer.WriteVector3(Utility.GetFieldValue<TBody, Vector3>(body, "HeadEulerAngle"));
+
+            // Head/eye to camera
+            writer.Write(meido.HeadToCam);
+            writer.Write(meido.EyeToCam);
+
+            // face
+            Dictionary<string, float> faceDict = meido.SerializeFace();
+            writer.Write(faceDict.Count);
+            foreach (var (hash, value) in faceDict)
+            {
+                writer.Write(hash);
+                writer.Write(value);
+            }
+        }
+
+        private static void SerializeBody(Meido meido, BinaryWriter writer)
+        {
+            writer.WriteVersion(bodyVersion);
+
+            // pose
+            var poseBuffer = meido.SerializePose(true);
+            writer.Write(poseBuffer.Length);
+            writer.Write(poseBuffer);
+
+            PoseInfoSerializer.Serialize(meido.CachedPose, writer);
+        }
+
+        private static void SerializeClothing(Meido meido, BinaryWriter writer)
+        {
+            var maid = meido.Maid;
+            var body = meido.Body;
+
+            writer.WriteVersion(clothingVersion);
+
+            // body visible
+            writer.Write(body.GetMask(TBody.SlotID.body));
+
+            // clothing
+            foreach (var clothingSlot in MaidDressingPane.ClothingSlots)
+            {
+                var value = true;
+                if (clothingSlot == TBody.SlotID.wear)
+                {
+                    if (MaidDressingPane.WearSlots.Any(slot => body.GetSlotLoaded(slot)))
+                    {
+                        value = MaidDressingPane.WearSlots.Any(slot => body.GetMask(slot));
+                    }
+                }
+                else if (clothingSlot == TBody.SlotID.megane)
+                {
+                    var slots = new[] { TBody.SlotID.megane, TBody.SlotID.accHead };
+                    if (slots.Any(slot => body.GetSlotLoaded(slot))) { value = slots.Any(slot => body.GetMask(slot)); }
+                }
+                else if (body.GetSlotLoaded(clothingSlot)) value = body.GetMask(clothingSlot);
+
+                writer.Write(value);
+            }
+
+            // zurashi and mekure
+            writer.Write(meido.CurlingFront);
+            writer.Write(meido.CurlingBack);
+            writer.Write(meido.PantsuShift);
+
+            // mpn attach props
+            var hasKousokuUpper = body.GetSlotLoaded(TBody.SlotID.kousoku_upper);
+            writer.Write(hasKousokuUpper);
+            writer.Write(maid.GetProp(MPN.kousoku_upper).strTempFileName);
+
+            var hasKousokuLower = body.GetSlotLoaded(TBody.SlotID.kousoku_lower);
+            writer.Write(hasKousokuLower);
+            writer.Write(maid.GetProp(MPN.kousoku_lower).strTempFileName);
+
+            // hair/skirt gravity
+            writer.Write(meido.HairGravityActive);
+            writer.Write(meido.HairGravityControl.Control.transform.localPosition);
+
+            writer.Write(meido.SkirtGravityActive);
+            writer.Write(meido.SkirtGravityControl.Control.transform.localPosition);
+        }
+
+        private static void DeserializeHead(Meido meido, BinaryReader reader, SceneMetadata metadata)
+        {
+            var body = meido.Body;
+
+            _ = reader.ReadVersion();
+
+            body.quaDefEyeL = reader.ReadQuaternion() * meido.DefaultEyeRotL;
+            body.quaDefEyeR = reader.ReadQuaternion() * meido.DefaultEyeRotR;
+
+            var freeLook = meido.FreeLook = reader.ReadBoolean();
+            var offsetLookTarget = reader.ReadVector3();
+            var headEulerAngle = reader.ReadVector3();
+
+            if (freeLook) body.offsetLookTarget = offsetLookTarget;
+
+            if (!metadata.MMConverted)
+            {
+                Utility.SetFieldValue(body, "HeadEulerAngleG", Vector3.zero);
+                Utility.SetFieldValue(body, "HeadEulerAngle", headEulerAngle);
+            }
+
+            meido.HeadToCam = reader.ReadBoolean();
+            meido.EyeToCam = reader.ReadBoolean();
+
+            var faceBlendCount = reader.ReadInt32();
+            for (var i = 0; i < faceBlendCount; i++)
+            {
+                var hash = reader.ReadString();
+                var value = reader.ReadSingle();
+                meido.SetFaceBlendValue(hash, value);
+            }
+        }
+
+        private static void DeserializeBody(Meido meido, BinaryReader reader, SceneMetadata metadata)
+        {
+            _ = reader.ReadVersion();
+
+            var muneSetting = new KeyValuePair<bool, bool>(true, true);
+            if (metadata.MMConverted) meido.IKManager.Deserialize(reader);
+            else
+            {
+                var poseBufferLength = reader.ReadInt32();
+                byte[] poseBuffer = reader.ReadBytes(poseBufferLength);
+                muneSetting = meido.SetFrameBinary(poseBuffer);
+            }
+
+            var poseInfo = PoseInfoSerializer.Deserialize(reader, metadata);
+            Utility.SetPropertyValue(meido, nameof(Meido.CachedPose), poseInfo);
+            
+            meido.SetMune(!muneSetting.Key, true);
+            meido.SetMune(!muneSetting.Value);
+        }
+
+        private static void DeserializeClothing(Meido meido, BinaryReader reader, SceneMetadata metadata)
+        {
+            var body = meido.Body;
+
+            _ = reader.ReadVersion();
+
+            meido.SetBodyMask(reader.ReadBoolean());
+
+            foreach (var clothingSlot in MaidDressingPane.ClothingSlots)
+            {
+                var value = reader.ReadBoolean();
+                if (metadata.MMConverted) continue;
+
+                if (clothingSlot == TBody.SlotID.wear)
+                {
+                    body.SetMask(TBody.SlotID.wear, value);
+                    body.SetMask(TBody.SlotID.mizugi, value);
+                    body.SetMask(TBody.SlotID.onepiece, value);
+                }
+                else if (clothingSlot == TBody.SlotID.megane)
+                {
+                    body.SetMask(TBody.SlotID.megane, value);
+                    body.SetMask(TBody.SlotID.accHead, value);
+                }
+                else if (body.GetSlotLoaded(clothingSlot)) body.SetMask(clothingSlot, value);
+            }
+
+            // zurashi and mekure
+            var curlingFront = reader.ReadBoolean();
+            var curlingBack = reader.ReadBoolean();
+            var curlingPantsu = reader.ReadBoolean();
+
+            if (!metadata.MMConverted)
+            {
+                if (meido.CurlingFront != curlingFront) meido.SetCurling(Meido.Curl.Front, curlingFront);
+                if (meido.CurlingBack != curlingBack) meido.SetCurling(Meido.Curl.Back, curlingBack);
+                meido.SetCurling(Meido.Curl.Shift, curlingPantsu);
+            }
+
+            // MPN attach upper prop
+            var hasKousokuUpper = reader.ReadBoolean();
+            var upperMenuFile = reader.ReadString();
+            if (hasKousokuUpper) meido.SetMpnProp(new MpnAttachProp(MPN.kousoku_upper, upperMenuFile), false);
+
+            // MPN attach lower prop
+            var hasKousokuLower = reader.ReadBoolean();
+            var lowerMenuFile = reader.ReadString();
+            if (hasKousokuLower) meido.SetMpnProp(new MpnAttachProp(MPN.kousoku_lower, lowerMenuFile), false);
+
+            // hair gravity
+            var hairGravityActive = reader.ReadBoolean();
+            var hairPosition = reader.ReadVector3();
+            meido.HairGravityActive = hairGravityActive;
+            if (meido.HairGravityActive) meido.ApplyGravity(hairPosition);
+
+            // skirt gravity
+            var skirtGravityActive = reader.ReadBoolean();
+            var skirtPosition = reader.ReadVector3();
+            meido.SkirtGravityActive = skirtGravityActive;
+            if (meido.SkirtGravityActive) meido.ApplyGravity(skirtPosition, true);
+        }
+    }
+}

+ 15 - 0
src/MeidoPhotoStudio.Plugin/Serialization/SimpleSerializer.cs

@@ -0,0 +1,15 @@
+using System.IO;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public abstract class SimpleSerializer<T> : ISimpleSerializer
+    {
+        void ISimpleSerializer.Serialize(object obj, BinaryWriter writer) => Serialize((T) obj, writer);
+
+        object ISimpleSerializer.Deserialize(BinaryReader reader, SceneMetadata metadata)
+            => Deserialize(reader, metadata);
+
+        public abstract void Serialize(T obj, BinaryWriter writer);
+        public abstract T Deserialize(BinaryReader reader, SceneMetadata metadata);
+    }
+}

+ 66 - 0
src/MeidoPhotoStudio.Plugin/Serialization/SimpleSerializers/DragPointPropDTOSerializer.cs

@@ -0,0 +1,66 @@
+using System.IO;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class DragPointPropDTOSerializer : SimpleSerializer<DragPointPropDTO>
+    {
+        private const short version = 1;
+
+        private static SimpleSerializer<PropInfo> PropInfoSerializer => Serialization.GetSimple<PropInfo>();
+        private static SimpleSerializer<TransformDTO> TransformSerializer => Serialization.GetSimple<TransformDTO>();
+
+        private static SimpleSerializer<AttachPointInfo> AttachPointSerializer
+            => Serialization.GetSimple<AttachPointInfo>();
+
+        public override void Serialize(DragPointPropDTO dragPointDto, BinaryWriter writer)
+        {
+            writer.WriteVersion(version);
+
+            PropInfoSerializer.Serialize(dragPointDto.PropInfo, writer);
+
+            TransformSerializer.Serialize(dragPointDto.TransformDTO, writer);
+
+            AttachPointSerializer.Serialize(dragPointDto.AttachPointInfo, writer);
+
+            writer.Write(dragPointDto.ShadowCasting);
+        }
+
+        public override DragPointPropDTO Deserialize(BinaryReader reader, SceneMetadata metadata)
+        {
+            _ = reader.ReadVersion();
+
+            return new DragPointPropDTO
+            {
+                PropInfo = PropInfoSerializer.Deserialize(reader, metadata),
+                TransformDTO = TransformSerializer.Deserialize(reader, metadata),
+                AttachPointInfo = AttachPointSerializer.Deserialize(reader, metadata),
+                ShadowCasting = reader.ReadBoolean()
+            };
+        }
+    }
+
+    public class DragPointPropDTO
+    {
+        public TransformDTO TransformDTO { get; init; }
+        public AttachPointInfo AttachPointInfo { get; init; }
+        public PropInfo PropInfo { get; init; }
+        public bool ShadowCasting { get; init; }
+
+        public DragPointPropDTO() { }
+
+        public DragPointPropDTO(DragPointProp dragPoint)
+        {
+            TransformDTO = new TransformDTO(dragPoint.MyObject.transform);
+            ShadowCasting = dragPoint.ShadowCasting;
+            AttachPointInfo = dragPoint.AttachPointInfo;
+            PropInfo = dragPoint.Info;
+        }
+
+        public void Deconstruct(out TransformDTO transform, out AttachPointInfo attachPointInfo, out bool shadowCasting)
+        {
+            transform = TransformDTO;
+            attachPointInfo = AttachPointInfo;
+            shadowCasting = ShadowCasting;
+        }
+    }
+}

+ 25 - 0
src/MeidoPhotoStudio.Plugin/Serialization/SimpleSerializers/PoseInfoSerializer.cs

@@ -0,0 +1,25 @@
+using System.IO;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class PoseInfoSerializer : SimpleSerializer<PoseInfo>
+    {
+        private const short version = 1;
+
+        public override void Serialize(PoseInfo obj, BinaryWriter writer)
+        {
+            writer.WriteVersion(version);
+
+            writer.Write(obj.PoseGroup);
+            writer.Write(obj.Pose);
+            writer.Write(obj.CustomPose);
+        }
+
+        public override PoseInfo Deserialize(BinaryReader reader, SceneMetadata metadata)
+        {
+            _ = reader.ReadVersion();
+
+            return new PoseInfo(reader.ReadString(), reader.ReadString(), reader.ReadBoolean());
+        }
+    }
+}

+ 33 - 0
src/MeidoPhotoStudio.Plugin/Serialization/SimpleSerializers/PropInfoSerializer.cs

@@ -0,0 +1,33 @@
+using System.IO;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class PropInfoSerializer : SimpleSerializer<PropInfo>
+    {
+        private const short version = 1;
+
+        public override void Serialize(PropInfo info, BinaryWriter writer)
+        {
+            writer.WriteVersion(version);
+
+            writer.Write((int) info.Type);
+            writer.WriteNullableString(info.Filename);
+            writer.WriteNullableString(info.SubFilename);
+            writer.Write(info.MyRoomID);
+            writer.WriteNullableString(info.IconFile);
+        }
+
+        public override PropInfo Deserialize(BinaryReader reader, SceneMetadata metadata)
+        {
+            _ = reader.ReadVersion();
+
+            return new PropInfo ((PropInfo.PropType) reader.ReadInt32())
+            {
+                Filename = reader.ReadNullableString(),
+                SubFilename = reader.ReadNullableString(),
+                MyRoomID = reader.ReadInt32(),
+                IconFile = reader.ReadNullableString()
+            };
+        }
+    }
+}

+ 55 - 0
src/MeidoPhotoStudio.Plugin/Serialization/SimpleSerializers/TransformDTOSerializer.cs

@@ -0,0 +1,55 @@
+using System.IO;
+using UnityEngine;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public class TransformDTOSerializer : SimpleSerializer<TransformDTO>
+    {
+        private const short version = 1;
+
+        public override void Serialize(TransformDTO transform, BinaryWriter writer)
+        {
+            writer.WriteVersion(version);
+
+            writer.Write(transform.Position);
+            writer.Write(transform.Rotation);
+            writer.Write(transform.LocalPosition);
+            writer.Write(transform.LocalRotation);
+            writer.Write(transform.LocalScale);
+        }
+
+        public override TransformDTO Deserialize(BinaryReader reader, SceneMetadata metadata)
+        {
+            _ = reader.ReadVersion();
+
+            return new TransformDTO
+            {
+                Position = reader.ReadVector3(),
+                Rotation = reader.ReadQuaternion(),
+                LocalPosition = reader.ReadVector3(),
+                LocalRotation = reader.ReadQuaternion(),
+                LocalScale = reader.ReadVector3()
+            };
+        }
+    }
+
+    public class TransformDTO
+    {
+        public Vector3 Position { get; init; }
+        public Vector3 LocalPosition { get; init; }
+        public Quaternion Rotation { get; init; } = Quaternion.identity;
+        public Quaternion LocalRotation { get; init; } = Quaternion.identity;
+        public Vector3 LocalScale { get; init; } = Vector3.one;
+
+        public TransformDTO() { }
+
+        public TransformDTO(Transform transform)
+        {
+            Position = transform.position;
+            LocalPosition = transform.localPosition;
+            Rotation = transform.rotation;
+            LocalRotation = transform.localRotation;
+            LocalScale = transform.localScale;
+        }
+    }
+}

+ 144 - 0
src/MeidoPhotoStudio.Plugin/Translation.cs

@@ -0,0 +1,144 @@
+using System;
+using System.Linq;
+using System.IO;
+using System.Collections.Generic;
+using Newtonsoft.Json.Linq;
+using BepInEx.Configuration;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public static class Translation
+    {
+        private const string settingsHeader = "Translation";
+        private static readonly string[] props = { "ui", "props", "bg", "face" };
+        private static Dictionary<string, Dictionary<string, string>> Translations;
+        private static readonly ConfigEntry<string> currentLanguage;
+        private static readonly ConfigEntry<bool> suppressWarnings;
+        private static bool forceSuppressWarnings;
+        private static bool suppressWarningsCached;
+        public static bool SuppressWarnings
+        {
+            get => suppressWarningsCached;
+            set
+            {
+                suppressWarningsCached = value;
+                suppressWarnings.Value = value;
+            }
+        }
+        public static string CurrentLanguage
+        {
+            get => currentLanguage.Value;
+            set => currentLanguage.Value = value;
+        }
+        public static event EventHandler ReloadTranslationEvent;
+
+        static Translation()
+        {
+            currentLanguage = Configuration.Config.Bind(
+                settingsHeader, "Language",
+                "en",
+                "Directory to pull translations from"
+                + "\nTranslations are found in the 'Translations' folder"
+            );
+
+            suppressWarnings = Configuration.Config.Bind(
+                settingsHeader, "SuppressWarnings",
+                false,
+                "Suppress translation warnings from showing up in the console"
+            );
+
+            suppressWarningsCached = !suppressWarnings.Value;
+        }
+
+        public static void Initialize(string language)
+        {
+            forceSuppressWarnings = false;
+
+            string rootTranslationPath = Path.Combine(Constants.configPath, Constants.translationDirectory);
+            string currentTranslationPath = Path.Combine(rootTranslationPath, language);
+
+            Translations = new Dictionary<string, Dictionary<string, string>>(
+                StringComparer.InvariantCultureIgnoreCase
+            );
+
+            if (!Directory.Exists(currentTranslationPath))
+            {
+                Utility.LogError(
+                    $"No translations found for '{language}' in '{currentTranslationPath}'"
+                );
+                forceSuppressWarnings = true;
+                return;
+            }
+
+            foreach (string prop in props)
+            {
+                string translationFile = $"translation.{prop}.json";
+                try
+                {
+                    string translationPath = Path.Combine(currentTranslationPath, translationFile);
+
+                    string translationJson = File.ReadAllText(translationPath);
+
+                    JObject translation = JObject.Parse(translationJson);
+
+                    foreach (JProperty translationProp in translation.AsJEnumerable())
+                    {
+                        JToken token = translationProp.Value;
+                        Translations[translationProp.Path] = new Dictionary<string, string>(
+                            token.ToObject<Dictionary<string, string>>(), StringComparer.InvariantCultureIgnoreCase
+                        );
+                    }
+                }
+                catch
+                {
+                    forceSuppressWarnings = true;
+                    Utility.LogError($"Could not find translation file '{translationFile}'");
+                }
+            }
+        }
+
+        public static void ReinitializeTranslation()
+        {
+            Initialize(CurrentLanguage);
+            ReloadTranslationEvent?.Invoke(null, EventArgs.Empty);
+        }
+
+        public static bool Has(string category, string text, bool warn = false)
+        {
+            warn = !forceSuppressWarnings && !SuppressWarnings && warn;
+            if (!Translations.ContainsKey(category))
+            {
+                if (warn) Utility.LogWarning($"Could not translate '{text}': category '{category}' was not found");
+                return false;
+            }
+
+            if (!Translations[category].ContainsKey(text))
+            {
+                if (warn)
+                {
+                    Utility.LogWarning(
+                        $"Could not translate '{text}': '{text}' was not found in category '{category}'"
+                    );
+                }
+                return false;
+            }
+
+            return true;
+        }
+
+        public static string Get(string category, string text, bool warn = true)
+        {
+            return Has(category, text, warn) ? Translations[category][text] : text;
+        }
+
+        public static string[] GetArray(string category, IEnumerable<string> list)
+        {
+            return GetList(category, list).ToArray();
+        }
+
+        public static IEnumerable<string> GetList(string category, IEnumerable<string> list)
+        {
+            return list.Select(uiName => Get(category, uiName));
+        }
+    }
+}

+ 427 - 0
src/MeidoPhotoStudio.Plugin/Utility.cs

@@ -0,0 +1,427 @@
+using System;
+using System.Collections.Generic;
+using System.Text.RegularExpressions;
+using System.IO;
+using System.Reflection;
+using UnityEngine;
+using System.Linq;
+using Ionic.Zlib;
+
+namespace MeidoPhotoStudio.Plugin
+{
+    public static class Utility
+    {
+        private const BindingFlags bindingFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic
+            | BindingFlags.Static;
+        internal static readonly byte[] pngHeader = { 137, 80, 78, 71, 13, 10, 26, 10 };
+        internal static readonly byte[] pngEnd = System.Text.Encoding.ASCII.GetBytes("IEND");
+        internal static readonly Regex guidRegEx = new Regex(
+            @"^[a-f0-9]{8}(\-[a-f0-9]{4}){3}\-[a-f0-9]{12}$", RegexOptions.IgnoreCase
+        );
+        internal static readonly GameObject mousePositionGo;
+        internal static readonly MousePosition mousePosition;
+        public static readonly BepInEx.Logging.ManualLogSource Logger
+            = BepInEx.Logging.Logger.CreateLogSource(MeidoPhotoStudio.pluginName);
+        public enum ModKey
+        {
+            Control, Shift, Alt
+        }
+        public static string Timestamp => $"{DateTime.Now:yyyyMMddHHmmss}";
+        public static Vector3 MousePosition => mousePosition.Position;
+
+        static Utility()
+        {
+            mousePositionGo = new GameObject();
+            mousePosition = mousePositionGo.AddComponent<MousePosition>();
+        }
+
+        public static void LogInfo(object data) => Logger.LogInfo(data);
+
+        public static void LogMessage(object data) => Logger.LogMessage(data);
+
+        public static void LogWarning(object data) => Logger.LogWarning(data);
+
+        public static void LogError(object data) => Logger.LogError(data);
+
+        public static void LogDebug(object data) => Logger.LogDebug(data);
+
+        public static int Wrap(int value, int min, int max)
+        {
+            max--;
+            return value < min ? max : value > max ? min : value;
+        }
+
+        public static int GetPix(int num) => (int)((1f + (((Screen.width / 1280f) - 1f) * 0.6f)) * num);
+
+        public static float Bound(float value, float left, float right)
+        {
+            return left > (double)right ? Mathf.Clamp(value, right, left) : Mathf.Clamp(value, left, right);
+        }
+
+        public static int Bound(int value, int left, int right)
+        {
+            return left > right ? Mathf.Clamp(value, right, left) : Mathf.Clamp(value, left, right);
+        }
+
+        public static Texture2D MakeTex(int width, int height, Color color)
+        {
+            Color[] colors = new Color[width * height];
+            for (int i = 0; i < colors.Length; i++)
+            {
+                colors[i] = color;
+            }
+            Texture2D texture2D = new Texture2D(width, height);
+            texture2D.SetPixels(colors);
+            texture2D.Apply();
+            return texture2D;
+        }
+
+        public static FieldInfo GetFieldInfo<T>(string field) => typeof(T).GetField(field, bindingFlags);
+
+        public static TValue GetFieldValue<TType, TValue>(TType instance, string field)
+        {
+            FieldInfo fieldInfo = GetFieldInfo<TType>(field);
+            if (fieldInfo == null || (!fieldInfo.IsStatic && instance == null)) return default;
+            return (TValue)fieldInfo.GetValue(instance);
+        }
+
+        public static void SetFieldValue<TType, TValue>(TType instance, string name, TValue value)
+        {
+            GetFieldInfo<TType>(name).SetValue(instance, value);
+        }
+
+        public static PropertyInfo GetPropertyInfo<T>(string field) => typeof(T).GetProperty(field, bindingFlags);
+
+        public static TValue GetPropertyValue<TType, TValue>(TType instance, string property)
+        {
+            var propertyInfo = GetPropertyInfo<TType>(property);
+            return propertyInfo == null ? default : (TValue) propertyInfo.GetValue(instance, null);
+        }
+        
+        public static void SetPropertyValue<TType, TValue>(TType instance, string name, TValue value) 
+            => GetPropertyInfo<TType>(name).SetValue(instance, value, null);
+
+        public static bool AnyMouseDown()
+        {
+            return Input.GetMouseButtonDown(0) || Input.GetMouseButtonDown(1) || Input.GetMouseButtonDown(2);
+        }
+
+        public static string ScreenshotFilename()
+        {
+            string screenShotDir = Path.Combine(
+                GameMain.Instance.SerializeStorageManager.StoreDirectoryPath, "ScreenShot"
+            );
+            if (!Directory.Exists(screenShotDir)) Directory.CreateDirectory(screenShotDir);
+            return Path.Combine(screenShotDir, $"img{Timestamp}.png");
+        }
+
+        public static string TempScreenshotFilename()
+        {
+            return Path.Combine(Path.GetTempPath(), $"cm3d2_{Guid.NewGuid()}.png");
+        }
+
+        public static void ShowMouseExposition(string text, float time = 2f)
+        {
+            MouseExposition mouseExposition = MouseExposition.GetObject();
+            mouseExposition.SetText(text, time);
+        }
+
+        public static bool IsGuidString(string guid)
+        {
+            if (string.IsNullOrEmpty(guid) || guid.Length != 36) return false;
+            return guidRegEx.IsMatch(guid);
+        }
+
+        public static string HandItemToOdogu(string menu)
+        {
+            menu = menu.Substring(menu.IndexOf('_') + 1);
+            menu = menu.Substring(0, menu.IndexOf("_i_.menu"));
+            menu = $"odogu_{menu}";
+            return menu;
+        }
+
+        public static void FixGameObjectScale(GameObject go)
+        {
+            Vector3 scale = go.transform.localScale;
+            float largest = Mathf.Max(scale.x, Mathf.Max(scale.y, scale.z));
+            go.transform.localScale = Vector3.one * (float)Math.Round(largest, 3);
+        }
+
+        public static string SanitizePathPortion(string path)
+        {
+            char[] invalid = Path.GetInvalidFileNameChars();
+            path = path.Trim();
+            path = string.Join("_", path.Split(invalid)).Replace(".", "").Trim('_');
+            return path;
+        }
+
+        public static string GP01FbFaceHash(TMorph face, string hash)
+        {
+            if ((face.bodyskin.PartsVersion >= 120) && (hash != "eyeclose3") && hash.StartsWith("eyeclose"))
+            {
+                if (hash == "eyeclose") hash += '1';
+                hash += TMorph.crcFaceTypesStr[(int)face.GetFaceTypeGP01FB()];
+            }
+            return hash;
+        }
+
+        public static void ResizeToFit(Texture2D texture, int maxWidth, int maxHeight)
+        {
+            int width = texture.width;
+            int height = texture.height;
+            if (width != maxWidth || height != maxHeight)
+            {
+                float scale = Mathf.Min(maxWidth / (float)width, maxHeight / (float)height);
+                width = Mathf.RoundToInt(width * scale);
+                height = Mathf.RoundToInt(height * scale);
+                TextureScale.Bilinear(texture, width, height);
+            }
+        }
+
+        public static bool BytesEqual(byte[] buffer, byte[] other)
+        {
+            if (buffer.Length != other.Length) return false;
+            for (int i = 0; i < buffer.Length; i++)
+            {
+                if (buffer[i] != other[i]) return false;
+            }
+            return true;
+        }
+
+        public static bool IsPngFile(Stream stream)
+        {
+            byte[] buffer = new byte[8];
+            stream.Read(buffer, 0, 8);
+            return BytesEqual(buffer, pngHeader);
+        }
+
+        public static bool SeekPngEnd(Stream stream)
+        {
+            byte[] buffer = new byte[8];
+            stream.Read(buffer, 0, 8);
+            if (!BytesEqual(buffer, pngHeader)) return false;
+            buffer = new byte[4];
+            do
+            {
+                stream.Read(buffer, 0, 4);
+                if (BitConverter.IsLittleEndian) Array.Reverse(buffer);
+                uint length = BitConverter.ToUInt32(buffer, 0);
+                stream.Read(buffer, 0, 4);
+                stream.Seek(length + 4L, SeekOrigin.Current);
+            } while (!BytesEqual(buffer, pngEnd));
+            return true;
+        }
+
+        public static void WriteToFile(string name, System.Collections.Generic.IEnumerable<string> list)
+        {
+            if (Path.GetExtension(name) != ".txt") name += ".txt";
+            File.WriteAllLines(Path.Combine(Constants.configPath, name), list.ToArray());
+        }
+
+        public static void WriteToFile(string name, byte[] data)
+        {
+            File.WriteAllBytes(Path.Combine(Constants.configPath, name), data);
+        }
+    }
+
+    public class MousePosition : MonoBehaviour
+    {
+        private Vector3 mousePosition;
+        public Vector3 Position => mousePosition;
+
+        private void Awake()
+        {
+            DontDestroyOnLoad(this);
+            mousePosition = Input.mousePosition;
+        }
+
+        private void Update()
+        {
+            if (Input.GetMouseButton(0))
+            {
+                mousePosition.x += Input.GetAxis("Mouse X") * 20;
+                mousePosition.y += Input.GetAxis("Mouse Y") * 20;
+            }
+            else mousePosition = Input.mousePosition;
+        }
+    }
+
+    public static class KeyValuePairExtensions
+    {
+        public static void Deconstruct<TKey, TValue>(
+            this KeyValuePair<TKey, TValue> kvp, out TKey key, out TValue value
+        )
+        {
+            key = kvp.Key;
+            value = kvp.Value;
+        }
+    }
+
+    public static class StreamExtensions
+    {
+        public static void CopyTo(this Stream stream, Stream outStream)
+        {
+            var buf = new byte[1024 * 32];
+            int length;
+            while ((length = stream.Read(buf, 0, buf.Length)) > 0) 
+                outStream.Write(buf, 0, length);
+        }
+
+        public static MemoryStream Decompress(this MemoryStream stream)
+        {
+            var dataMemoryStream = new MemoryStream();
+            using var compressionStream = new DeflateStream(stream, CompressionMode.Decompress, true);
+
+            compressionStream.CopyTo(dataMemoryStream);
+            compressionStream.Flush();
+
+            dataMemoryStream.Position = 0L;
+
+            return dataMemoryStream;
+        }
+
+        public static DeflateStream GetCompressionStream(this MemoryStream stream)
+            => new(stream, CompressionMode.Compress);
+    }
+
+    public static class CameraUtility
+    {
+        public static CameraMain MainCamera => GameMain.Instance.MainCamera;
+        public static UltimateOrbitCamera UOCamera { get; } =
+            GameMain.Instance.MainCamera.GetComponent<UltimateOrbitCamera>();
+        
+        public static void StopSpin()
+        {
+            Utility.SetFieldValue(UOCamera, "xVelocity", 0f);
+            Utility.SetFieldValue(UOCamera, "yVelocity", 0f);
+        }
+
+        public static void StopMovement() => MainCamera.SetTargetPos(MainCamera.GetTargetPos());
+
+        public static void StopAll()
+        {
+            StopSpin();
+            StopMovement();
+        }
+
+        public static void ForceCalcNearClip(this CameraMain camera)
+        {
+            camera.StopAllCoroutines();
+            camera.m_bCalcNearClip = false;
+            camera.camera.nearClipPlane = 0.01f;
+        }
+
+        public static void ResetCalcNearClip(this CameraMain camera)
+        {
+            if (camera.m_bCalcNearClip) return;
+            camera.StopAllCoroutines();
+            camera.m_bCalcNearClip = true;
+            camera.Start();
+        }
+    }
+
+    public static class BinaryExtensions
+    {
+        public static string ReadNullableString(this BinaryReader binaryReader)
+        {
+            return binaryReader.ReadBoolean() ? binaryReader.ReadString() : null;
+        }
+
+        public static void WriteNullableString(this BinaryWriter binaryWriter, string str)
+        {
+            binaryWriter.Write(str != null);
+            if (str != null) binaryWriter.Write(str);
+        }
+
+        public static void Write(this BinaryWriter binaryWriter, Vector3 vector3)
+        {
+            binaryWriter.Write(vector3.x);
+            binaryWriter.Write(vector3.y);
+            binaryWriter.Write(vector3.z);
+        }
+
+        public static void WriteVector3(this BinaryWriter binaryWriter, Vector3 vector3)
+        {
+            binaryWriter.Write(vector3.x);
+            binaryWriter.Write(vector3.y);
+            binaryWriter.Write(vector3.z);
+        }
+
+        public static Vector2 ReadVector2(this BinaryReader binaryReader)
+        {
+            return new Vector2(binaryReader.ReadSingle(), binaryReader.ReadSingle());
+        }
+
+        public static Vector3 ReadVector3(this BinaryReader binaryReader)
+        {
+            return new Vector3(
+                binaryReader.ReadSingle(), binaryReader.ReadSingle(), binaryReader.ReadSingle()
+            );
+        }
+
+        public static Vector4 ReadVector4(this BinaryReader binaryReader)
+        {
+            return new Vector4(
+                binaryReader.ReadSingle(), binaryReader.ReadSingle(),
+                binaryReader.ReadSingle(), binaryReader.ReadSingle()
+            );
+        }
+
+        public static void Write(this BinaryWriter binaryWriter, Quaternion quaternion)
+        {
+            binaryWriter.Write(quaternion.x);
+            binaryWriter.Write(quaternion.y);
+            binaryWriter.Write(quaternion.z);
+            binaryWriter.Write(quaternion.w);
+        }
+
+        public static void WriteQuaternion(this BinaryWriter binaryWriter, Quaternion quaternion)
+        {
+            binaryWriter.Write(quaternion.x);
+            binaryWriter.Write(quaternion.y);
+            binaryWriter.Write(quaternion.z);
+            binaryWriter.Write(quaternion.w);
+        }
+
+        public static Quaternion ReadQuaternion(this BinaryReader binaryReader)
+        {
+            return new Quaternion
+            (
+                binaryReader.ReadSingle(), binaryReader.ReadSingle(),
+                binaryReader.ReadSingle(), binaryReader.ReadSingle()
+            );
+        }
+
+        public static void Write(this BinaryWriter binaryWriter, Color colour)
+        {
+            binaryWriter.Write(colour.r);
+            binaryWriter.Write(colour.g);
+            binaryWriter.Write(colour.b);
+            binaryWriter.Write(colour.a);
+        }
+
+        public static void WriteColour(this BinaryWriter binaryWriter, Color colour)
+        {
+            binaryWriter.Write(colour.r);
+            binaryWriter.Write(colour.g);
+            binaryWriter.Write(colour.b);
+            binaryWriter.Write(colour.a);
+        }
+
+        public static Color ReadColour(this BinaryReader binaryReader)
+        {
+            return new Color
+            (
+                binaryReader.ReadSingle(), binaryReader.ReadSingle(),
+                binaryReader.ReadSingle(), binaryReader.ReadSingle()
+            );
+        }
+
+        public static Matrix4x4 ReadMatrix4x4(this BinaryReader binaryReader)
+        {
+            Matrix4x4 matrix = default;
+            for (var i = 0; i < 16; i++) matrix[i] = binaryReader.ReadSingle();
+            return matrix;
+        }
+    }
+}