Host the avatar test as a test suite; drop visual_novel_core dependency

- The Live2D avatar integration test (driver, overlay, test model "ren")
  moves here from the separate live2d_test mod as test_suites/avatar. It
  is mounted as the virtual mod live2d__avatar only with
  --itest=live2d_avatar; normal launches never load it.
- The runtime library needs only the interface mod; the content mod
  dependency is gone.
This commit is contained in:
cthangover committed 2026-10-17 14:28:01 +00:00
1 parent a9d4417fce
commit bec5743f13
25 files changed
+13542 -2

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
mod_live2d__avatar_name = Live2D Test
mod_live2d__avatar_desc = Test and demo mod for the live2d runtime: the sample Ren model (expressions, motions) shown by the home_outside overlay scene plus an avatar test driver.
+2
View File
@@ -0,0 +1,2 @@
mod_live2d__avatar_name = Live2D: тестовый мод
mod_live2d__avatar_desc = Тестовый мод для live2d: демо-модель Ren (эмоции, анимации) в оверлее сцены home_outside и тестовый драйвер аватара.
+23
View File
@@ -0,0 +1,23 @@
{
"name": "Live2D Test",
"description": "Test and demo mod for the live2d runtime: the sample Ren model (expressions, motions) shown by the home_outside overlay scene plus an avatar test driver. Requires live2d>=1.",
"sources": [
"src/*.cs"
],
"ui": [
{
"id": "live2d_overlay",
"kind": "overlay",
"scene": "home_outside",
"slot": "ModBeforeUIPanel",
"scene_path": "scenes/live2d_overlay.tscn",
"once": false,
"show_on_start": true,
"priority": 10
}
],
"depends": [
"visual_novel_core>=1",
"interface>=1"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

+57
View File
@@ -0,0 +1,57 @@
============================================================
Sample Model
Ren Foster - PRO
============================================================
This model focuses on learning expressions that use the rich drawing features introduced in Cubism 5.3.
It serves as a reference for using features such as alpha blend for creating masks flexibly,
and offscreen drawing for displaying semi-transparency cleanly by drawing multiple objects together.
It is designed for use in environments with sufficient machine specifications,
such as video streaming on a PC using tracking software like nizima LIVE.
------------------------------
License Agreement
------------------------------
You need to agree "Free Material License Agreement" and "Terms of Use" to use this sample model.
General Users and Small-Scale Enterprise Users can use the model for commercial purposes by accepting the "Free Material License Agreement".
Parties other than General User or Small-Scale Enterprise User are allowed to use the model for Internal or Supervision Purpose.
Please refer to this agreement for more details.
For more information on licensing, please see the following page.
https://www.live2d.com/en/download/sample-data/
------------------------------
Creator
------------------------------
- Illustration: Live2D Inc.
- Modeling: Live2D Inc.
------------------------------
Sample Data Composition
------------------------------
- Model data (.cmo3)
- Basic motion (.can3)
- Embedded file set (.runtime folder)
- Model data (.moc3)
- Expression data (.exp3.json)
- Motion data (.motion3.json)
- Model setting file (.model3.json)
- Physical calculation setting file (.physics3.json)
- Display auxiliary file (.cdi3.json)
------------------------------
Release Note
------------------------------
- 2026-01-20
- Released.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,77 @@
{
"Type": "Live2D Expression",
"FadeInTime": 0.5,
"FadeOutTime": 0.5,
"Parameters": [
{
"Id": "ParamEyeLOpen",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeLSmile",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeROpen",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeRSmile",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeSize",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLAngle",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRAngle",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthForm",
"Value": 0,
"Blend": "Add"
}
]
}
@@ -0,0 +1,77 @@
{
"Type": "Live2D Expression",
"FadeInTime": 0.5,
"FadeOutTime": 0.5,
"Parameters": [
{
"Id": "ParamEyeLOpen",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamEyeLSmile",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamEyeROpen",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamEyeRSmile",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamEyeSize",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLAngle",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRAngle",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthForm",
"Value": 0,
"Blend": "Add"
}
]
}
@@ -0,0 +1,77 @@
{
"Type": "Live2D Expression",
"FadeInTime": 0.5,
"FadeOutTime": 0.5,
"Parameters": [
{
"Id": "ParamEyeLOpen",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamEyeLSmile",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeROpen",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamEyeRSmile",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeSize",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLAngle",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRAngle",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthForm",
"Value": 0,
"Blend": "Add"
}
]
}
@@ -0,0 +1,77 @@
{
"Type": "Live2D Expression",
"FadeInTime": 0.5,
"FadeOutTime": 0.5,
"Parameters": [
{
"Id": "ParamEyeLOpen",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeLSmile",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeROpen",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeRSmile",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeSize",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLAngle",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowRAngle",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowLForm",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowRForm",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamMouthForm",
"Value": -1,
"Blend": "Add"
}
]
}
@@ -0,0 +1,77 @@
{
"Type": "Live2D Expression",
"FadeInTime": 0.5,
"FadeOutTime": 0.5,
"Parameters": [
{
"Id": "ParamEyeLOpen",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeLSmile",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeROpen",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeRSmile",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeSize",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLAngle",
"Value": 0.7,
"Blend": "Add"
},
{
"Id": "ParamBrowRAngle",
"Value": 0.7,
"Blend": "Add"
},
{
"Id": "ParamBrowLForm",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowRForm",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamMouthForm",
"Value": -1,
"Blend": "Add"
}
]
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

@@ -0,0 +1,647 @@
{
"Version": 3,
"Parameters": [
{
"Id": "ParamAngleX",
"GroupId": "ParamGroupFace",
"Name": "Angle X"
},
{
"Id": "ParamAngleY",
"GroupId": "ParamGroupFace",
"Name": "Angle Y"
},
{
"Id": "ParamAngleZ",
"GroupId": "ParamGroupFace",
"Name": "Angle Z"
},
{
"Id": "ParamEyeLOpen",
"GroupId": "ParamGroupEye",
"Name": "Eye L Open"
},
{
"Id": "ParamEyeLSmile",
"GroupId": "ParamGroupEye",
"Name": "Eye L Smile"
},
{
"Id": "ParamEyeROpen",
"GroupId": "ParamGroupEye",
"Name": "Eye R Open"
},
{
"Id": "ParamEyeRSmile",
"GroupId": "ParamGroupEye",
"Name": "Eye R Smile"
},
{
"Id": "ParamEyeBallX",
"GroupId": "ParamGroupEyeballs",
"Name": "Eyeballs X"
},
{
"Id": "ParamEyeBallY",
"GroupId": "ParamGroupEyeballs",
"Name": "Eyeballs Y"
},
{
"Id": "ParamEyeSize",
"GroupId": "ParamGroupEyeballs",
"Name": "Eyeballs Shrink"
},
{
"Id": "ParamBrowLY",
"GroupId": "ParamGroupBrows",
"Name": "Eyebrow L Y"
},
{
"Id": "ParamBrowRY",
"GroupId": "ParamGroupBrows",
"Name": "Eyebrow R Y"
},
{
"Id": "ParamBrowLX",
"GroupId": "ParamGroupBrows",
"Name": "Eyebrow L X"
},
{
"Id": "ParamBrowRX",
"GroupId": "ParamGroupBrows",
"Name": "Eyebrow R X"
},
{
"Id": "ParamBrowLAngle",
"GroupId": "ParamGroupBrows",
"Name": "Eyebrow L Angle"
},
{
"Id": "ParamBrowRAngle",
"GroupId": "ParamGroupBrows",
"Name": "Eyebrow R Angle"
},
{
"Id": "ParamBrowLForm",
"GroupId": "ParamGroupBrows",
"Name": "Eyebrow L Form"
},
{
"Id": "ParamBrowRForm",
"GroupId": "ParamGroupBrows",
"Name": "Eyebrow R Form"
},
{
"Id": "ParamMouthForm",
"GroupId": "ParamGroupMouth",
"Name": "Mouth Form"
},
{
"Id": "ParamMouthOpenY",
"GroupId": "ParamGroupMouth",
"Name": "Mouth Open"
},
{
"Id": "ParamBodyAngleX",
"GroupId": "ParamGroupBody",
"Name": "Body Rotatoin X"
},
{
"Id": "ParamBodyAngleY",
"GroupId": "ParamGroupBody",
"Name": "Body Rotatoin Y"
},
{
"Id": "ParamBodyAngleZ",
"GroupId": "ParamGroupBody",
"Name": "Body Rotatoin Z"
},
{
"Id": "ParamWaistAngleZ",
"GroupId": "ParamGroupBody",
"Name": "Waist Rotation Z"
},
{
"Id": "ParamBodyCenter",
"GroupId": "ParamGroupBody",
"Name": "Weight Shift"
},
{
"Id": "ParamBreath",
"GroupId": "ParamGroupBody",
"Name": "Breath"
},
{
"Id": "ParamLeftShoulderUp",
"GroupId": "ParamGroupBody",
"Name": "Shoulder L Y"
},
{
"Id": "ParamRightShoulderUp",
"GroupId": "ParamGroupBody",
"Name": "Shoulder R Y"
},
{
"Id": "ParamLegR",
"GroupId": "ParamGroupBody",
"Name": "Leg R"
},
{
"Id": "ParamLegL",
"GroupId": "ParamGroupBody",
"Name": "Leg L"
},
{
"Id": "ParamBodyAngleX2",
"GroupId": "ParamGroup",
"Name": "Body Rotation X2"
},
{
"Id": "ParamBodyAngleY2",
"GroupId": "ParamGroup",
"Name": "Body Rotation Y2"
},
{
"Id": "ParamBodyCenter2",
"GroupId": "ParamGroup",
"Name": "Weight Shift 2"
},
{
"Id": "ParamLeftShoulderUp2",
"GroupId": "ParamGroup",
"Name": "Shoulder L Y 2"
},
{
"Id": "ParamRightShoulderUp2",
"GroupId": "ParamGroup",
"Name": "Shoulder R Y 2"
},
{
"Id": "ParamArmL01",
"GroupId": "ParamGroupArmL",
"Name": "Arm L Shoulder Rotation"
},
{
"Id": "ParamArmL02",
"GroupId": "ParamGroupArmL",
"Name": "Arm L Elbow Rotation"
},
{
"Id": "ParamArmL03",
"GroupId": "ParamGroupArmL",
"Name": "Arm L Wrist Rotation"
},
{
"Id": "ParamHandL",
"GroupId": "ParamGroupArmL",
"Name": "Hand L"
},
{
"Id": "ParamHandLDrawOrder",
"GroupId": "ParamGroupArmL",
"Name": "Hand L Draw Order"
},
{
"Id": "ParamArmR01",
"GroupId": "ParamGroupArmR",
"Name": "Arm R Shoulder Rotation"
},
{
"Id": "ParamArmR02",
"GroupId": "ParamGroupArmR",
"Name": "Arm R Elbow Rotation"
},
{
"Id": "ParamArmR03",
"GroupId": "ParamGroupArmR",
"Name": "Arm R Wrist Rotation"
},
{
"Id": "ParamHandR",
"GroupId": "ParamGroupArmR",
"Name": "Hand R"
},
{
"Id": "ParamHandRDrawOrder",
"GroupId": "ParamGroupArmR",
"Name": "Hand R Draw Order"
},
{
"Id": "ParamHairFront",
"GroupId": "ParamGroupSway",
"Name": "Hair Sway Front"
},
{
"Id": "ParamHairSide",
"GroupId": "ParamGroupSway",
"Name": "Hair Move Side"
},
{
"Id": "ParamHairBack",
"GroupId": "ParamGroupSway",
"Name": "Hair Move Back"
},
{
"Id": "ParamHairFrontFuwa",
"GroupId": "ParamGroupSway",
"Name": "Front Hair Fluff"
},
{
"Id": "ParamHairSideFuwa",
"GroupId": "ParamGroupSway",
"Name": "Side Hair Flap"
},
{
"Id": "ParamHairBackFuwa",
"GroupId": "ParamGroupSway",
"Name": "Back Hair Fluff"
},
{
"Id": "ParamCollarString",
"GroupId": "ParamGroupSway",
"Name": "Collar Strap Sway"
},
{
"Id": "ParamNecklace1",
"GroupId": "ParamGroupSway",
"Name": "Necklace Sway 1"
},
{
"Id": "ParamNecklace2",
"GroupId": "ParamGroupSway",
"Name": "Necklace Sway 2"
},
{
"Id": "ParamNecklace3",
"GroupId": "ParamGroupSway",
"Name": "Necklace Sway 3"
},
{
"Id": "ParamPullHandle",
"GroupId": "ParamGroupSway",
"Name": "Puller Sway"
},
{
"Id": "ParamPocketString",
"GroupId": "ParamGroupSway",
"Name": "Pocket Band Sway"
},
{
"Id": "ParamJacket",
"GroupId": "ParamGroupSway",
"Name": "Jacket Sway"
},
{
"Id": "ParamCollarFuwa",
"GroupId": "ParamGroupSway",
"Name": "Collar Flap"
},
{
"Id": "ParamCollarStringFuwa",
"GroupId": "ParamGroupSway",
"Name": "Collar Strap Flap"
},
{
"Id": "ParamHologram",
"GroupId": "ParamGroupEffect",
"Name": "Hologram"
},
{
"Id": "ParamTransparent",
"GroupId": "ParamGroupEffect",
"Name": "See-through"
},
{
"Id": "ParamHologramPattern",
"GroupId": "ParamGroupEffect",
"Name": "Hologram Pattern"
},
{
"Id": "ParamMonochrome",
"GroupId": "ParamGroupEffect",
"Name": "Monochrome"
},
{
"Id": "ParamNight",
"GroupId": "ParamGroupEffect",
"Name": "Night"
},
{
"Id": "ParamLight",
"GroupId": "ParamGroupEffect",
"Name": "Light"
},
{
"Id": "ParamDisplayWidth1",
"GroupId": "ParamGroupEffect",
"Name": "Display 1 Width"
},
{
"Id": "ParamDisplayHeight1",
"GroupId": "ParamGroupEffect",
"Name": "Display 1 Height"
},
{
"Id": "ParamDisplayWidth2",
"GroupId": "ParamGroupEffect",
"Name": "Display 2 Width"
},
{
"Id": "ParamDisplayHeight2",
"GroupId": "ParamGroupEffect",
"Name": "Display 2 Height"
},
{
"Id": "ParamDisplayAngle",
"GroupId": "ParamGroupEffect",
"Name": "Display Angle"
},
{
"Id": "ParamNoise",
"GroupId": "ParamGroupEffect",
"Name": "Noise"
},
{
"Id": "ParamNoiseRotate",
"GroupId": "ParamGroupEffect",
"Name": "Noise Rotation"
}
],
"ParameterGroups": [
{
"Id": "ParamGroupFace",
"GroupId": "",
"Name": "Face"
},
{
"Id": "ParamGroupEye",
"GroupId": "",
"Name": "Eyes"
},
{
"Id": "ParamGroupEyeballs",
"GroupId": "",
"Name": "Eyeballs"
},
{
"Id": "ParamGroupBrows",
"GroupId": "",
"Name": "Eyebrows"
},
{
"Id": "ParamGroupMouth",
"GroupId": "",
"Name": "Mouth"
},
{
"Id": "ParamGroupBody",
"GroupId": "",
"Name": "Body"
},
{
"Id": "ParamGroup",
"GroupId": "",
"Name": "Body Physics"
},
{
"Id": "ParamGroupArmL",
"GroupId": "",
"Name": "Arm L"
},
{
"Id": "ParamGroupArmR",
"GroupId": "",
"Name": "Arm R"
},
{
"Id": "ParamGroupSway",
"GroupId": "",
"Name": "Sway"
},
{
"Id": "ParamGroupEffect",
"GroupId": "",
"Name": "Effect"
}
],
"Parts": [
{
"Id": "PartAll",
"Name": "Overall"
},
{
"Id": "PartSketch",
"Name": "[Guide Image]"
},
{
"Id": "PartEffect",
"Name": "Effect"
},
{
"Id": "PartCore",
"Name": "Core"
},
{
"Id": "PartHairFront",
"Name": "Front Hair"
},
{
"Id": "PartBrow",
"Name": "Eyebrow"
},
{
"Id": "PartEyeL",
"Name": "Eye L"
},
{
"Id": "PartEyeR",
"Name": "Eye R"
},
{
"Id": "PartNose",
"Name": "Nose"
},
{
"Id": "PartMouth",
"Name": "Mouth"
},
{
"Id": "PartFace",
"Name": "Face"
},
{
"Id": "PartEar",
"Name": "Ears"
},
{
"Id": "PartNeck",
"Name": "Neck"
},
{
"Id": "PartHairBack",
"Name": "Back Hair"
},
{
"Id": "PartJacket01",
"Name": "Jacket"
},
{
"Id": "PartArmL",
"Name": "Arm L"
},
{
"Id": "PartArmR",
"Name": "Arm R"
},
{
"Id": "PartBody",
"Name": "Upper Body"
},
{
"Id": "PartDisplay",
"Name": "Display"
},
{
"Id": "PartMonochrome",
"Name": "Monochrome"
},
{
"Id": "PartNight",
"Name": "Night"
},
{
"Id": "PartHologram",
"Name": "Hologram"
},
{
"Id": "PartTransparent",
"Name": "See-through"
},
{
"Id": "PartHairShadow",
"Name": "Hair Shadow"
},
{
"Id": "PartEyeballL",
"Name": "Eyeball L"
},
{
"Id": "PartEyeballR",
"Name": "Eyeball R"
},
{
"Id": "PartHairline",
"Name": "Hairline L"
},
{
"Id": "PartJacket02",
"Name": "Jacket"
},
{
"Id": "PartJacketArmL",
"Name": "Jacket Arm L"
},
{
"Id": "PartJacketArmR",
"Name": "Jacket Arm R"
},
{
"Id": "PartJacketShadow",
"Name": "Jacket Shadow"
},
{
"Id": "PartNecklace",
"Name": "Necklace"
},
{
"Id": "PartShirt",
"Name": "Shirt"
},
{
"Id": "PartLowerBody",
"Name": "Lower Body"
},
{
"Id": "PartDisplayPaint",
"Name": "Display Fill"
},
{
"Id": "PartShadow",
"Name": "Shadow"
},
{
"Id": "PartBrow2",
"Name": "Eyebrow"
},
{
"Id": "PartEyeL2",
"Name": "Eye L"
},
{
"Id": "PartEyeR2",
"Name": "Eye R"
},
{
"Id": "PartHairShadowOut",
"Name": "Hair Shadow Out"
},
{
"Id": "PartPupilL",
"Name": "Pupil L"
},
{
"Id": "PartPupilR",
"Name": "Pupil R"
},
{
"Id": "PartArmLDrawOrder",
"Name": "Arm L Draw Order"
},
{
"Id": "PartArmRDrawOrder",
"Name": "Arm R Draw Order"
},
{
"Id": "PartBodyOut",
"Name": "Torso Out"
},
{
"Id": "PartDisplay01",
"Name": "Display 1"
},
{
"Id": "PartDisplay02",
"Name": "Display 2"
},
{
"Id": "PartEyeballL2",
"Name": "Eyeball L"
},
{
"Id": "PartEyeballR2",
"Name": "Eyeball R"
},
{
"Id": "PartHairBackOut",
"Name": "Back Hair Out"
},
{
"Id": "PartPupilL2",
"Name": "Pupil L"
},
{
"Id": "PartPupilR2",
"Name": "Pupil R"
}
],
"CombinedParameters": [
[
"ParamAngleX",
"ParamAngleY"
],
[
"ParamMouthForm",
"ParamMouthOpenY"
]
]
}
Binary file not shown.
@@ -0,0 +1,75 @@
{
"Version": 3,
"FileReferences": {
"Moc": "ren.moc3",
"Textures": [
"ren.4096/texture_00.png"
],
"Physics": "ren.physics3.json",
"DisplayInfo": "ren.cdi3.json",
"Expressions": [
{
"Name": "exp_01",
"File": "expressions/exp_01.exp3.json"
},
{
"Name": "exp_02",
"File": "expressions/exp_02.exp3.json"
},
{
"Name": "exp_03",
"File": "expressions/exp_03.exp3.json"
},
{
"Name": "exp_04",
"File": "expressions/exp_04.exp3.json"
},
{
"Name": "exp_05",
"File": "expressions/exp_05.exp3.json"
}
],
"Motions": {
"Idle": [
{
"File": "motions/mtn_01.motion3.json"
}
],
"": [
{
"File": "motions/mtn_02.motion3.json"
},
{
"File": "motions/mtn_03.motion3.json"
}
]
}
},
"Groups": [
{
"Target": "Parameter",
"Name": "EyeBlink",
"Ids": [
"ParamEyeLOpen",
"ParamEyeROpen"
]
},
{
"Target": "Parameter",
"Name": "LipSync",
"Ids": [
"ParamMouthOpenY"
]
}
],
"HitAreas": [
{
"Id": "HitAreaBody",
"Name": "Body"
},
{
"Id": "HitAreaHead",
"Name": "Head"
}
]
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,6 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/Live2DAvatarTestDriver.cs" id="1_driver"]
[node name="Live2DAvatarTestDriver" type="Node2D"]
script = ExtResource("1_driver")
@@ -0,0 +1,13 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/Live2DOverlay.cs" id="1_script"]
[node name="Live2DOverlay" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1_script")
@@ -0,0 +1,290 @@
using System.Collections.Generic;
using System.IO;
using Cthangover.Core.Mods;
using Cthangover.Core.Utils;
using Cthangover.Live2D.Cubism.Framework;
using Cthangover.Live2D.Cubism.Services;
using Godot;
namespace Mods.Live2DTest
{
/// <summary>
/// Интеграционный тест Live2D-аватара: создаёт модель через мод-пайплайн
/// (тот же путь, что и <see cref="Live2DOverlay"/>), держит руки опущенными
/// секунду, затем поднимает правую руку через <c>ParamArmR01</c> и
/// проверяет смену позы компьютерным зрением по скриншотам вьюпорта:
/// <list type="bullet">
/// <item>детерминизм — два кадра статичной позы идентичны попиксельно;</item>
/// <item>видимость — команда смены позы перекрашивает значимую часть кадра;</item>
/// <item>силуэт — в верхнем боковом секторе (зона над плечом) площадь
/// аватара растёт после подъёма руки;</item>
/// <item>удержание — параметр держит поднятое значение между кадрами
/// (регрессия на Load/SaveParameters-брекет).</item>
/// </list>
/// Скриншоты сохраняются в tests/artifacts/live2d_avatar. Инстанцируется
/// игровой интеграционной сценой через TscnScenes (мод — единственное место
/// с компайл-тайм доступом к Live2D-обёртке); сам печатает [TEST_RESULT]
/// и завершает дерево с кодом выхода.
/// </summary>
public partial class Live2DAvatarTestDriver : Node2D
{
private const string ModId = "live2d__avatar";
private const string ModelPath = "models/runtime/ren.model3.json";
private const string LeftArmParam = "ParamArmL01";
private const string RightArmParam = "ParamArmR01";
/// <summary>Сколько держим опущенные руки до скриншота (по ТЗ — секунду).</summary>
private const float LoweredHoldSeconds = 1.0f;
/// <summary>Пауза между кадрами проверки детерминизма.</summary>
private const float StabilityGapSeconds = 0.4f;
/// <summary>Ожидание растеризации поднятой руки.</summary>
private const float RaisedSettleSeconds = 0.8f;
/// <summary>Порог «далёкий от фона» пиксель силуэта, 0..1.</summary>
private const float SilhouetteTolerance = 0.2f;
/// <summary>Порог значимого изменения канала для diff-подсчёта.</summary>
private const float DiffTolerance = 0.1f;
private int passed;
private int failed;
private string artifactDir;
public override void _Ready() => _ = RunAsync();
private async System.Threading.Tasks.Task RunAsync()
{
try
{
GameLogger.Log("TEST", "Live2DAvatarTestDriver: starting Live2D avatar integration test");
artifactDir = ProjectSettings.GlobalizePath("res://tests/artifacts/live2d_avatar");
Directory.CreateDirectory(artifactDir);
await RunTest();
}
catch (System.Exception ex)
{
Fail($"unexpected crash: {ex.GetType().Name}: {ex.Message}");
GameLogger.Log("TEST", ex.StackTrace ?? "(no stack)");
}
GameLogger.Log("TEST", $"Live2DAvatarTestDriver: {passed} passed, {failed} failed");
GameLogger.Log("TEST", $"[TEST_RESULT] name=live2d_avatar passed={passed} failed={failed}");
GetTree().Quit(failed == 0 ? 0 : 1);
}
private async System.Threading.Tasks.Task RunTest()
{
Pass($"driver started for mod '{ModId}'");
// Тот же путь создания, что в продовом оверлее; моргание выключено,
// чтобы кадры были пиксельно детерминированы.
var model = Live2DService.CreateModel(this, new Live2DModelConfig
{
ModelPath = ModelPath,
ModId = ModId,
NodeName = "Live2DTestAvatar",
EnableEyeBlink = false
});
if (model == null || model.Parameters.Length == 0)
{
Fail("avatar creation failed (no parameters)");
}
Pass($"avatar created: {model.Parameters.Length} parameters, canvas {model.CanvasWidth:F0}x{model.CanvasHeight:F0}");
var leftArm = model.FindParameter(LeftArmParam);
var rightArm = model.FindParameter(RightArmParam);
if (leftArm == null || rightArm == null)
{
Fail($"arm parameters not found ('{LeftArmParam}' / '{RightArmParam}')");
return;
}
Pass($"{LeftArmParam} [{leftArm.MinimumValue}..{leftArm.MaximumValue}], {RightArmParam} [{rightArm.MinimumValue}..{rightArm.MaximumValue}]");
// Фаза 1 — руки опущены, держим ~1 секунду.
model.SetLiveParameter(LeftArmParam, leftArm.MinimumValue);
model.SetLiveParameter(RightArmParam, rightArm.MinimumValue);
await Delay(LoweredHoldSeconds);
var lowered = await Capture();
await Delay(StabilityGapSeconds);
var stability = await Capture();
// Фаза 2 — поднимаем руку.
model.SetLiveParameter(RightArmParam, rightArm.MaximumValue);
await Delay(RaisedSettleSeconds);
var raised = await Capture();
SavePng("01_lowered.png", lowered);
SavePng("02_lowered_stability.png", stability);
SavePng("03_raised.png", raised);
Analyze(model, lowered, stability, raised);
}
/// <summary>
/// Компьютерное зрение по трём кадрам: детерминизм статичной позы,
/// видимая площадь смены позы, рост силуэта над плечом и удержание
/// параметром поднятого значения.
/// </summary>
private void Analyze(
CubismModelNode model, Image lowered, Image stability, Image raised)
{
var bg = lowered.GetPixel(2, 2);
Pass($"background reference color: {bg}");
var sil = SilhouetteBounds(lowered, bg);
if (sil.Size.X <= 0 || sil.Size.Y <= 0)
{
Fail("avatar silhouette not found on the lowered frame");
return;
}
Pass($"silhouette bbox: pos={sil.Position} size={sil.Size}");
int framePixels = lowered.GetWidth() * lowered.GetHeight();
// 1) Детерминизм: статичная поза должна рендериться идентично.
int stabilityDiff = CountChangedPixels(lowered, stability);
double stabilityRatio = stabilityDiff / (double)framePixels;
if (stabilityRatio <= 0.001)
Pass($"static pose deterministic: {stabilityDiff} changed px ({stabilityRatio:P3})");
else
Fail($"static pose not deterministic: {stabilityDiff} changed px ({stabilityRatio:P3}) — ambient animation is on");
// 2) Команда смены позы должна перекрасить значимую часть кадра.
int poseDiff = CountChangedPixels(lowered, raised);
double poseRatio = poseDiff / (double)framePixels;
if (poseRatio >= 0.003)
Pass($"pose change visible: {poseDiff} changed px ({poseRatio:P3})");
else
Fail($"pose change too small: {poseDiff} changed px ({poseRatio:P3})");
// 3) CV: поднятая рука добавляет силуэт ЗА пределами опущенного
// bbox'а на высоте плеча (лево или право — модель может быть
// зеркальна зрителю). Полосы фиксированы по bbox опущенной позы,
// поэтому не съезжают вместе с bbox поднятой руки.
int stripH = System.Math.Max(1, (int)(sil.Size.Y * 0.30f));
int stripY = (int)(sil.Position.Y + sil.Size.Y * 0.25f);
int stripW = System.Math.Max(1, (int)(sil.Size.X * 2.0f));
int margin = 4;
int leftX = System.Math.Max(0, (int)sil.Position.X - margin - stripW);
int rightX = System.Math.Min(lowered.GetWidth() - stripW, (int)sil.End.X + margin);
var leftStrip = new Rect2I(leftX, stripY, stripW, stripH);
var rightStrip = new Rect2I(rightX, stripY, stripW, stripH);
int loweredLeft = CountModelPixels(lowered, leftStrip, bg);
int raisedLeft = CountModelPixels(raised, leftStrip, bg);
int loweredRight = CountModelPixels(lowered, rightStrip, bg);
int raisedRight = CountModelPixels(raised, rightStrip, bg);
int gain = System.Math.Max(raisedLeft - loweredLeft, raisedRight - loweredRight);
int threshold = System.Math.Max(300, (int)(stripW * stripH * 0.02f));
GameLogger.Log("TEST",
$" side strips @shoulder: left {loweredLeft}->{raisedLeft}, right {loweredRight}->{raisedRight} px (strip {stripW}x{stripH}, need {threshold})");
if (gain >= threshold)
Pass($"raised arm silhouette appears beside the body: +{gain} px");
else
Fail($"no raised-arm silhouette beside the body: +{gain} px (threshold {threshold})");
// 4) Параметр держит поднятое значение между кадрами
// (регрессия на Load/SaveParameters-брекет и SetLiveParameter).
var armNow = model.FindParameter(RightArmParam);
if (armNow != null && System.Math.Abs(armNow.Value - armNow.MaximumValue) < 0.001f)
Pass($"{RightArmParam} holds MaximumValue across frames ({armNow.Value})");
else
Fail($"{RightArmParam} did not hold MaximumValue (value={armNow?.Value})");
}
private async System.Threading.Tasks.Task Delay(float seconds) =>
await ToSignal(GetTree().CreateTimer(seconds), SceneTreeTimer.SignalName.Timeout);
/// <summary>Ждёт обработки кадра и снимает вьюпорт в RGBA8.</summary>
private async System.Threading.Tasks.Task<Image> Capture()
{
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
var image = GetViewport().GetTexture().GetImage();
image.Convert(Image.Format.Rgba8);
return image;
}
private void SavePng(string fileName, Image image)
{
var path = Path.Combine(artifactDir, fileName);
if (image.SavePng(path) == Error.Ok)
Pass($"screenshot saved: {path}");
else
Fail($"SavePng failed for '{path}'");
}
private static Rect2 SilhouetteBounds(Image image, Color bg)
{
byte[] data = image.GetData();
int w = image.GetWidth(), h = image.GetHeight();
int minX = int.MaxValue, minY = int.MaxValue, maxX = -1, maxY = -1;
for (int y = 0; y < h; y++)
{
for (int x = 0; x < w; x++)
{
int i = (y * w + x) * 4;
if (IsFarFromBackground(data, i, bg))
{
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
return maxX < 0 ? new Rect2(0, 0, 0, 0) : new Rect2(minX, minY, maxX - minX + 1, maxY - minY + 1);
}
private static int CountModelPixels(Image image, Rect2I region, Color bg)
{
byte[] data = image.GetData();
int w = image.GetWidth();
int count = 0;
for (int y = region.Position.Y; y < region.End.Y; y++)
{
for (int x = region.Position.X; x < region.End.X; x++)
{
if (IsFarFromBackground(data, (y * w + x) * 4, bg)) count++;
}
}
return count;
}
private static int CountChangedPixels(Image a, Image b)
{
byte[] da = a.GetData(), db = b.GetData();
int count = 0;
int limit = System.Math.Min(da.Length, db.Length);
for (int i = 0; i < limit; i += 4)
{
if (System.Math.Abs(da[i] - db[i]) > DiffTolerance * 255f
|| System.Math.Abs(da[i + 1] - db[i + 1]) > DiffTolerance * 255f
|| System.Math.Abs(da[i + 2] - db[i + 2]) > DiffTolerance * 255f)
count++;
}
return count;
}
private static bool IsFarFromBackground(byte[] data, int i, Color bg) =>
System.Math.Abs(data[i] / 255f - bg.R) > SilhouetteTolerance
|| System.Math.Abs(data[i + 1] / 255f - bg.G) > SilhouetteTolerance
|| System.Math.Abs(data[i + 2] / 255f - bg.B) > SilhouetteTolerance;
private void Pass(string msg)
{
passed++;
GameLogger.Log("TEST", $" [PASS] {msg}");
}
private void Fail(string msg)
{
failed++;
GameLogger.Log("TEST", $" [FAIL] {msg}", LogLevel.Error);
}
}
}
+109
View File
@@ -0,0 +1,109 @@
using Cthangover.Core.UI;
using Cthangover.Core.Utils;
using Cthangover.Live2D.Cubism.Framework;
using Cthangover.Live2D.Cubism.Services;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
namespace Mods.Live2DTest
{
/// <summary>
/// Scene-driven replacement for the old live2d_test subscription snippet.
/// The overlay root is added to a shell slot by <see cref="UiHost"/>; it
/// spawns the Cubism model into the <c>Lastground</c> anchor and keeps a
/// transparent drag handle on top so the player can steer the model's head
/// parameters with the mouse. Created per <c>home_outside</c> visit
/// (once=false) and destroyed together with the overlay.
/// </summary>
public partial class Live2DOverlay : ModWidget
{
private const string ModelPath = "models/runtime/ren.model3.json";
private const string ModelNodeName = "Live2DTestModel";
private const string ContainerNodeName = "Lastground";
private CubismModelNode model;
private ColorRect dragHandle;
private bool dragging;
private float mouseStartX;
private float mouseStartY;
private float baseValueX;
private float baseValueY;
protected override void Construct()
{
MouseFilter = MouseFilterEnum.Ignore;
var container = UiHost.Instance?.FindNode(ContainerNodeName);
if (container == null)
{
GameLogger.Log("LIVE2D_TEST", $"Live2DOverlay: container '{ContainerNodeName}' not found", LogLevel.Error);
return;
}
model = Live2DService.CreateModel(container, ModelPath, ModelNodeName, "live2d__avatar");
if (model == null)
{
GameLogger.Log("LIVE2D_TEST", "Live2DOverlay: model creation failed", LogLevel.Error);
return;
}
dragHandle = new ColorRect
{
Name = "Live2DDragPanel",
MouseFilter = MouseFilterEnum.Stop,
Color = new Color(0, 1f, 0, 0f),
Size = new Vector2(400, 400),
AnchorLeft = 0.5f,
AnchorRight = 0.5f,
AnchorTop = 0.5f,
AnchorBottom = 0.5f,
OffsetLeft = -200f,
OffsetRight = 200f,
OffsetTop = -200f,
OffsetBottom = -200f
};
AddChild(dragHandle);
dragHandle.GuiInput += OnDragInput;
}
protected override void Destruct()
{
if (model != null && GodotObject.IsInstanceValid(model))
model.QueueFree();
model = null;
}
private void OnDragInput(InputEvent @event)
{
if (@event is InputEventMouseButton mb && mb.ButtonIndex == MouseButton.Left)
{
if (mb.Pressed)
{
dragging = true;
mouseStartX = mb.GlobalPosition.X;
mouseStartY = mb.GlobalPosition.Y;
baseValueX = model.FindParameter("ParamAngleX")?.Value ?? 0f;
baseValueY = model.FindParameter("ParamAngleY")?.Value ?? 0f;
}
else
{
dragging = false;
}
return;
}
if (!dragging || @event is not InputEventMouseMotion mm || model == null)
return;
var targetX = model.FindParameter("ParamAngleX");
var targetY = model.FindParameter("ParamAngleY");
var deltaX = (mm.GlobalPosition.X - mouseStartX) / 30f;
var deltaY = (mm.GlobalPosition.Y - mouseStartY) / 30f;
if (targetX != null)
model.SetLiveParameter("ParamAngleX", Mathf.Clamp(baseValueX + deltaX, targetX.MinimumValue, targetX.MaximumValue));
if (targetY != null)
model.SetLiveParameter("ParamAngleY", Mathf.Clamp(baseValueY + deltaY, targetY.MinimumValue, targetY.MaximumValue));
}
}
}