forked from metin-server/m2dev-client
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9065ca8d2 | ||
|
|
8767a2b3ac | ||
|
|
0edaea5993 | ||
|
|
5e79fc27e1 | ||
|
|
95fc2df7c0 | ||
| 1dba3e3c91 | |||
|
|
fdb9e98075 | ||
|
|
02061f6e07 | ||
|
|
b7e4514677 | ||
|
|
759b31d390 | ||
|
|
605f8765d5 | ||
|
|
6f70ef201a | ||
|
|
05af7e55b3 | ||
| 9c95590099 | |||
|
|
dd0643137f | ||
|
|
0aa8361f09 |
@@ -1,524 +0,0 @@
|
||||
BoundingSphereRadius 0.000000
|
||||
BoundingSpherePosition 0.000000 0.000000 0.000000
|
||||
|
||||
Group Particle
|
||||
{
|
||||
StartTime 0.176000
|
||||
List TimeEventPosition
|
||||
{
|
||||
0.000000 "MOVING_TYPE_DIRECT" 0.000000 0.000000 0.000000
|
||||
}
|
||||
|
||||
StaticRotation 0.000000 0.000000 0.000000
|
||||
Group EmitterProperty
|
||||
{
|
||||
MaxEmissionCount 1
|
||||
|
||||
CycleLength 10.000000
|
||||
CycleLoopEnable 1
|
||||
LoopCount 3
|
||||
|
||||
EmitterShape 0
|
||||
EmitterAdvancedType 0
|
||||
EmitterEmitFromEdgeFlag 0
|
||||
EmittingDirection 0.080000 0.000000 0.000000
|
||||
|
||||
List TimeEventEmittingSize
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TimeEventEmittingAngularVelocity
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TimeEventEmittingDirectionX
|
||||
{
|
||||
0.000000 5.000000
|
||||
}
|
||||
List TimeEventEmittingDirectionY
|
||||
{
|
||||
0.000000 -50.000000
|
||||
0.100000 -20.000000
|
||||
}
|
||||
List TimeEventEmittingDirectionZ
|
||||
{
|
||||
0.000000 10.000000
|
||||
0.050000 100.000000
|
||||
0.100000 1000.000000
|
||||
}
|
||||
List TimeEventEmittingVelocity
|
||||
{
|
||||
0.000000 15.000000
|
||||
}
|
||||
List TimeEventEmissionCountPerSecond
|
||||
{
|
||||
0.000000 1000.000000
|
||||
}
|
||||
List TimeEventLifeTime
|
||||
{
|
||||
0.000000 10.000000
|
||||
}
|
||||
List TimeEventSizeX
|
||||
{
|
||||
0.000000 60.000000
|
||||
}
|
||||
List TimeEventSizeY
|
||||
{
|
||||
0.000000 36.000000
|
||||
}
|
||||
}
|
||||
|
||||
Group ParticleProperty
|
||||
{
|
||||
SrcBlendType 5
|
||||
DestBlendType 6
|
||||
ColorOperationType 4
|
||||
BillboardType 1
|
||||
RotationType 0
|
||||
RotationSpeed 0.000000
|
||||
RotationRandomStartingBegin 0
|
||||
RotationRandomStartingEnd 0
|
||||
|
||||
AttachEnable 1
|
||||
StretchEnable 0
|
||||
|
||||
TexAniType 1
|
||||
TexAniDelay 0.040000
|
||||
TexAniRandomStartFrameEnable 0
|
||||
EnableFrustum 0
|
||||
|
||||
List TimeEventGravity
|
||||
{
|
||||
}
|
||||
List TimeEventAirResistance
|
||||
{
|
||||
}
|
||||
List TimeEventScaleX
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventScaleY
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventColorRed
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventColorGreen
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventColorBlue
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventAlpha
|
||||
{
|
||||
0.000000 1.000000
|
||||
0.737143 1.000000
|
||||
1.000000 0.000000
|
||||
}
|
||||
List TimeEventRotation
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TextureFiles
|
||||
{
|
||||
"D:\Ymir Work\effect\pet\halloween_2022_coffin_bat_01.dds"
|
||||
"D:\Ymir Work\effect\pet\halloween_2022_coffin_bat_02.dds"
|
||||
"D:\Ymir Work\effect\pet\halloween_2022_coffin_bat_03.dds"
|
||||
}
|
||||
}
|
||||
}
|
||||
Group Particle
|
||||
{
|
||||
StartTime 0.072000
|
||||
List TimeEventPosition
|
||||
{
|
||||
0.000000 "MOVING_TYPE_DIRECT" 0.000000 0.000000 0.000000
|
||||
}
|
||||
|
||||
StaticRotation 0.000000 0.000000 0.000000
|
||||
Group EmitterProperty
|
||||
{
|
||||
MaxEmissionCount 1
|
||||
|
||||
CycleLength 10.000000
|
||||
CycleLoopEnable 1
|
||||
LoopCount 3
|
||||
|
||||
EmitterShape 0
|
||||
EmitterAdvancedType 0
|
||||
EmitterEmitFromEdgeFlag 0
|
||||
EmittingDirection 0.080000 0.000000 0.000000
|
||||
|
||||
List TimeEventEmittingSize
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TimeEventEmittingAngularVelocity
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TimeEventEmittingDirectionX
|
||||
{
|
||||
0.000000 15.000000
|
||||
}
|
||||
List TimeEventEmittingDirectionY
|
||||
{
|
||||
0.000000 -50.000000
|
||||
0.100000 -20.000000
|
||||
}
|
||||
List TimeEventEmittingDirectionZ
|
||||
{
|
||||
0.000000 10.000000
|
||||
0.050000 50.000000
|
||||
0.100000 1000.000000
|
||||
}
|
||||
List TimeEventEmittingVelocity
|
||||
{
|
||||
0.000000 15.000000
|
||||
}
|
||||
List TimeEventEmissionCountPerSecond
|
||||
{
|
||||
0.000000 1000.000000
|
||||
}
|
||||
List TimeEventLifeTime
|
||||
{
|
||||
0.000000 10.000000
|
||||
}
|
||||
List TimeEventSizeX
|
||||
{
|
||||
0.000000 72.000000
|
||||
}
|
||||
List TimeEventSizeY
|
||||
{
|
||||
0.000000 48.000000
|
||||
}
|
||||
}
|
||||
|
||||
Group ParticleProperty
|
||||
{
|
||||
SrcBlendType 5
|
||||
DestBlendType 6
|
||||
ColorOperationType 4
|
||||
BillboardType 1
|
||||
RotationType 0
|
||||
RotationSpeed 0.000000
|
||||
RotationRandomStartingBegin 0
|
||||
RotationRandomStartingEnd 0
|
||||
|
||||
AttachEnable 1
|
||||
StretchEnable 0
|
||||
|
||||
TexAniType 1
|
||||
TexAniDelay 0.040000
|
||||
TexAniRandomStartFrameEnable 0
|
||||
EnableFrustum 0
|
||||
|
||||
List TimeEventGravity
|
||||
{
|
||||
}
|
||||
List TimeEventAirResistance
|
||||
{
|
||||
}
|
||||
List TimeEventScaleX
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventScaleY
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventColorRed
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventColorGreen
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventColorBlue
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventAlpha
|
||||
{
|
||||
0.000000 1.000000
|
||||
0.737143 1.000000
|
||||
1.000000 0.000000
|
||||
}
|
||||
List TimeEventRotation
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TextureFiles
|
||||
{
|
||||
"D:\Ymir Work\effect\pet\halloween_2022_coffin_bat_01.dds"
|
||||
"D:\Ymir Work\effect\pet\halloween_2022_coffin_bat_02.dds"
|
||||
"D:\Ymir Work\effect\pet\halloween_2022_coffin_bat_03.dds"
|
||||
}
|
||||
}
|
||||
}
|
||||
Group Particle
|
||||
{
|
||||
StartTime 0.000000
|
||||
List TimeEventPosition
|
||||
{
|
||||
0.000000 "MOVING_TYPE_DIRECT" 0.000000 0.000000 0.000000
|
||||
}
|
||||
|
||||
StaticRotation 0.000000 0.000000 0.000000
|
||||
Group EmitterProperty
|
||||
{
|
||||
MaxEmissionCount 1
|
||||
|
||||
CycleLength 10.000000
|
||||
CycleLoopEnable 1
|
||||
LoopCount 3
|
||||
|
||||
EmitterShape 0
|
||||
EmitterAdvancedType 0
|
||||
EmitterEmitFromEdgeFlag 0
|
||||
EmittingDirection 0.080000 0.000000 0.000000
|
||||
|
||||
List TimeEventEmittingSize
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TimeEventEmittingAngularVelocity
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TimeEventEmittingDirectionX
|
||||
{
|
||||
0.000000 50.000000
|
||||
}
|
||||
List TimeEventEmittingDirectionY
|
||||
{
|
||||
0.000000 -50.000000
|
||||
0.100000 -20.000000
|
||||
}
|
||||
List TimeEventEmittingDirectionZ
|
||||
{
|
||||
0.000000 10.000000
|
||||
0.050000 100.000000
|
||||
0.100000 1000.000000
|
||||
}
|
||||
List TimeEventEmittingVelocity
|
||||
{
|
||||
0.000000 5.000000
|
||||
0.083429 15.000000
|
||||
}
|
||||
List TimeEventEmissionCountPerSecond
|
||||
{
|
||||
0.000000 1000.000000
|
||||
}
|
||||
List TimeEventLifeTime
|
||||
{
|
||||
0.000000 10.000000
|
||||
}
|
||||
List TimeEventSizeX
|
||||
{
|
||||
0.000000 60.000000
|
||||
}
|
||||
List TimeEventSizeY
|
||||
{
|
||||
0.000000 36.000000
|
||||
}
|
||||
}
|
||||
|
||||
Group ParticleProperty
|
||||
{
|
||||
SrcBlendType 5
|
||||
DestBlendType 6
|
||||
ColorOperationType 4
|
||||
BillboardType 1
|
||||
RotationType 0
|
||||
RotationSpeed 0.000000
|
||||
RotationRandomStartingBegin 0
|
||||
RotationRandomStartingEnd 0
|
||||
|
||||
AttachEnable 1
|
||||
StretchEnable 0
|
||||
|
||||
TexAniType 1
|
||||
TexAniDelay 0.040000
|
||||
TexAniRandomStartFrameEnable 0
|
||||
EnableFrustum 0
|
||||
|
||||
List TimeEventGravity
|
||||
{
|
||||
}
|
||||
List TimeEventAirResistance
|
||||
{
|
||||
}
|
||||
List TimeEventScaleX
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventScaleY
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventColorRed
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventColorGreen
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventColorBlue
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventAlpha
|
||||
{
|
||||
0.000000 1.000000
|
||||
0.737143 1.000000
|
||||
1.000000 0.000000
|
||||
}
|
||||
List TimeEventRotation
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TextureFiles
|
||||
{
|
||||
"D:\Ymir Work\effect\pet\halloween_2022_coffin_bat_01.dds"
|
||||
"D:\Ymir Work\effect\pet\halloween_2022_coffin_bat_02.dds"
|
||||
"D:\Ymir Work\effect\pet\halloween_2022_coffin_bat_03.dds"
|
||||
}
|
||||
}
|
||||
}
|
||||
Group Particle
|
||||
{
|
||||
StartTime 0.000000
|
||||
List TimeEventPosition
|
||||
{
|
||||
0.000000 "MOVING_TYPE_DIRECT" 0.000000 0.000000 0.000000
|
||||
0.216000 "MOVING_TYPE_DIRECT" 0.000000 -20.000000 20.000000
|
||||
}
|
||||
|
||||
StaticRotation 0.000000 0.000000 0.000000
|
||||
Group EmitterProperty
|
||||
{
|
||||
MaxEmissionCount 30
|
||||
|
||||
CycleLength 10.000000
|
||||
CycleLoopEnable 1
|
||||
LoopCount 3
|
||||
|
||||
EmitterShape 3
|
||||
EmitterAdvancedType 1
|
||||
EmittingRadius 80.000000
|
||||
EmitterEmitFromEdgeFlag 0
|
||||
EmittingDirection 0.010000 0.010000 0.010000
|
||||
|
||||
List TimeEventEmittingSize
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TimeEventEmittingAngularVelocity
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TimeEventEmittingDirectionX
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TimeEventEmittingDirectionY
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TimeEventEmittingDirectionZ
|
||||
{
|
||||
0.000000 20.000000
|
||||
}
|
||||
List TimeEventEmittingVelocity
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventEmissionCountPerSecond
|
||||
{
|
||||
0.000000 1000.000000
|
||||
}
|
||||
List TimeEventLifeTime
|
||||
{
|
||||
0.000000 10.000000
|
||||
}
|
||||
List TimeEventSizeX
|
||||
{
|
||||
0.000000 128.000000
|
||||
}
|
||||
List TimeEventSizeY
|
||||
{
|
||||
0.000000 128.000000
|
||||
}
|
||||
}
|
||||
|
||||
Group ParticleProperty
|
||||
{
|
||||
SrcBlendType 5
|
||||
DestBlendType 6
|
||||
ColorOperationType 4
|
||||
BillboardType 1
|
||||
RotationType 4
|
||||
RotationSpeed 56.000000
|
||||
RotationRandomStartingBegin 360
|
||||
RotationRandomStartingEnd 0
|
||||
|
||||
AttachEnable 0
|
||||
StretchEnable 0
|
||||
|
||||
TexAniType 0
|
||||
TexAniDelay 0.040000
|
||||
TexAniRandomStartFrameEnable 0
|
||||
EnableFrustum 0
|
||||
|
||||
List TimeEventGravity
|
||||
{
|
||||
}
|
||||
List TimeEventAirResistance
|
||||
{
|
||||
0.000000 0.000000
|
||||
0.034286 0.100000
|
||||
}
|
||||
List TimeEventScaleX
|
||||
{
|
||||
0.000000 0.500000
|
||||
1.000000 1.000000
|
||||
}
|
||||
List TimeEventScaleY
|
||||
{
|
||||
0.000000 0.500000
|
||||
1.000000 1.000000
|
||||
}
|
||||
List TimeEventColorRed
|
||||
{
|
||||
0.000000 0.819608
|
||||
}
|
||||
List TimeEventColorGreen
|
||||
{
|
||||
0.000000 0.780392
|
||||
}
|
||||
List TimeEventColorBlue
|
||||
{
|
||||
0.000000 0.682353
|
||||
}
|
||||
List TimeEventAlpha
|
||||
{
|
||||
0.000000 1.000000
|
||||
0.112821 0.000000
|
||||
}
|
||||
List TimeEventRotation
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TextureFiles
|
||||
{
|
||||
"D:\Ymir Work\effect\monster2\smoke_dirt1.dds"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,654 +0,0 @@
|
||||
BoundingSphereRadius 0.000000
|
||||
BoundingSpherePosition 0.000000 0.000000 0.000000
|
||||
|
||||
Group Particle
|
||||
{
|
||||
StartTime 0.176000
|
||||
List TimeEventPosition
|
||||
{
|
||||
0.000000 "MOVING_TYPE_DIRECT" 0.000000 0.000000 0.000000
|
||||
}
|
||||
|
||||
StaticRotation 0.000000 0.000000 0.000000
|
||||
Group EmitterProperty
|
||||
{
|
||||
MaxEmissionCount 1
|
||||
|
||||
CycleLength 20.000000
|
||||
CycleLoopEnable 1
|
||||
LoopCount 2
|
||||
|
||||
EmitterShape 0
|
||||
EmitterAdvancedType 0
|
||||
EmitterEmitFromEdgeFlag 0
|
||||
EmittingDirection 0.080000 0.000000 0.000000
|
||||
|
||||
List TimeEventEmittingSize
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TimeEventEmittingAngularVelocity
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TimeEventEmittingDirectionX
|
||||
{
|
||||
0.000000 5.000000
|
||||
}
|
||||
List TimeEventEmittingDirectionY
|
||||
{
|
||||
0.000000 50.000000
|
||||
0.100000 20.000000
|
||||
}
|
||||
List TimeEventEmittingDirectionZ
|
||||
{
|
||||
0.000000 10.000000
|
||||
0.050000 100.000000
|
||||
0.100000 1000.000000
|
||||
}
|
||||
List TimeEventEmittingVelocity
|
||||
{
|
||||
0.000000 15.000000
|
||||
}
|
||||
List TimeEventEmissionCountPerSecond
|
||||
{
|
||||
0.000000 1000.000000
|
||||
}
|
||||
List TimeEventLifeTime
|
||||
{
|
||||
0.000000 20.000000
|
||||
}
|
||||
List TimeEventSizeX
|
||||
{
|
||||
0.000000 60.000000
|
||||
}
|
||||
List TimeEventSizeY
|
||||
{
|
||||
0.000000 36.000000
|
||||
}
|
||||
}
|
||||
|
||||
Group ParticleProperty
|
||||
{
|
||||
SrcBlendType 5
|
||||
DestBlendType 6
|
||||
ColorOperationType 4
|
||||
BillboardType 1
|
||||
RotationType 0
|
||||
RotationSpeed 0.000000
|
||||
RotationRandomStartingBegin 0
|
||||
RotationRandomStartingEnd 0
|
||||
|
||||
AttachEnable 1
|
||||
StretchEnable 0
|
||||
|
||||
TexAniType 1
|
||||
TexAniDelay 0.040000
|
||||
TexAniRandomStartFrameEnable 0
|
||||
EnableFrustum 0
|
||||
|
||||
List TimeEventGravity
|
||||
{
|
||||
}
|
||||
List TimeEventAirResistance
|
||||
{
|
||||
}
|
||||
List TimeEventScaleX
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventScaleY
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventColorRed
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventColorGreen
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventColorBlue
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventAlpha
|
||||
{
|
||||
0.000000 1.000000
|
||||
0.376923 1.000000
|
||||
0.500000 0.000000
|
||||
}
|
||||
List TimeEventRotation
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TextureFiles
|
||||
{
|
||||
"D:\Ymir Work\effect\pet\halloween_2022_coffin_bat_01.dds"
|
||||
"D:\Ymir Work\effect\pet\halloween_2022_coffin_bat_02.dds"
|
||||
"D:\Ymir Work\effect\pet\halloween_2022_coffin_bat_03.dds"
|
||||
}
|
||||
}
|
||||
}
|
||||
Group Particle
|
||||
{
|
||||
StartTime 0.072000
|
||||
List TimeEventPosition
|
||||
{
|
||||
0.000000 "MOVING_TYPE_DIRECT" 0.000000 0.000000 0.000000
|
||||
}
|
||||
|
||||
StaticRotation 0.000000 0.000000 0.000000
|
||||
Group EmitterProperty
|
||||
{
|
||||
MaxEmissionCount 1
|
||||
|
||||
CycleLength 20.000000
|
||||
CycleLoopEnable 1
|
||||
LoopCount 2
|
||||
|
||||
EmitterShape 0
|
||||
EmitterAdvancedType 0
|
||||
EmitterEmitFromEdgeFlag 0
|
||||
EmittingDirection 0.080000 0.000000 0.000000
|
||||
|
||||
List TimeEventEmittingSize
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TimeEventEmittingAngularVelocity
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TimeEventEmittingDirectionX
|
||||
{
|
||||
0.000000 15.000000
|
||||
}
|
||||
List TimeEventEmittingDirectionY
|
||||
{
|
||||
0.000000 50.000000
|
||||
0.100000 20.000000
|
||||
}
|
||||
List TimeEventEmittingDirectionZ
|
||||
{
|
||||
0.000000 10.000000
|
||||
0.050000 50.000000
|
||||
0.100000 1000.000000
|
||||
}
|
||||
List TimeEventEmittingVelocity
|
||||
{
|
||||
0.000000 15.000000
|
||||
}
|
||||
List TimeEventEmissionCountPerSecond
|
||||
{
|
||||
0.000000 1000.000000
|
||||
}
|
||||
List TimeEventLifeTime
|
||||
{
|
||||
0.000000 20.000000
|
||||
}
|
||||
List TimeEventSizeX
|
||||
{
|
||||
0.000000 72.000000
|
||||
}
|
||||
List TimeEventSizeY
|
||||
{
|
||||
0.000000 48.000000
|
||||
}
|
||||
}
|
||||
|
||||
Group ParticleProperty
|
||||
{
|
||||
SrcBlendType 5
|
||||
DestBlendType 6
|
||||
ColorOperationType 4
|
||||
BillboardType 1
|
||||
RotationType 0
|
||||
RotationSpeed 0.000000
|
||||
RotationRandomStartingBegin 0
|
||||
RotationRandomStartingEnd 0
|
||||
|
||||
AttachEnable 1
|
||||
StretchEnable 0
|
||||
|
||||
TexAniType 1
|
||||
TexAniDelay 0.040000
|
||||
TexAniRandomStartFrameEnable 0
|
||||
EnableFrustum 0
|
||||
|
||||
List TimeEventGravity
|
||||
{
|
||||
}
|
||||
List TimeEventAirResistance
|
||||
{
|
||||
}
|
||||
List TimeEventScaleX
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventScaleY
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventColorRed
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventColorGreen
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventColorBlue
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventAlpha
|
||||
{
|
||||
0.000000 1.000000
|
||||
0.387179 1.000000
|
||||
0.517949 0.000000
|
||||
}
|
||||
List TimeEventRotation
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TextureFiles
|
||||
{
|
||||
"D:\Ymir Work\effect\pet\halloween_2022_coffin_bat_01.dds"
|
||||
"D:\Ymir Work\effect\pet\halloween_2022_coffin_bat_02.dds"
|
||||
"D:\Ymir Work\effect\pet\halloween_2022_coffin_bat_03.dds"
|
||||
}
|
||||
}
|
||||
}
|
||||
Group Particle
|
||||
{
|
||||
StartTime 0.072000
|
||||
List TimeEventPosition
|
||||
{
|
||||
0.000000 "MOVING_TYPE_DIRECT" 0.000000 0.000000 0.000000
|
||||
}
|
||||
|
||||
StaticRotation 0.000000 0.000000 0.000000
|
||||
Group EmitterProperty
|
||||
{
|
||||
MaxEmissionCount 1
|
||||
|
||||
CycleLength 20.000000
|
||||
CycleLoopEnable 1
|
||||
LoopCount 2
|
||||
|
||||
EmitterShape 0
|
||||
EmitterAdvancedType 0
|
||||
EmitterEmitFromEdgeFlag 0
|
||||
EmittingDirection 0.080000 0.000000 0.000000
|
||||
|
||||
List TimeEventEmittingSize
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TimeEventEmittingAngularVelocity
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TimeEventEmittingDirectionX
|
||||
{
|
||||
0.000000 15.000000
|
||||
}
|
||||
List TimeEventEmittingDirectionY
|
||||
{
|
||||
0.000000 50.000000
|
||||
0.100000 20.000000
|
||||
}
|
||||
List TimeEventEmittingDirectionZ
|
||||
{
|
||||
0.000000 10.000000
|
||||
0.050000 50.000000
|
||||
0.100000 1000.000000
|
||||
}
|
||||
List TimeEventEmittingVelocity
|
||||
{
|
||||
0.000000 15.000000
|
||||
}
|
||||
List TimeEventEmissionCountPerSecond
|
||||
{
|
||||
0.000000 1000.000000
|
||||
}
|
||||
List TimeEventLifeTime
|
||||
{
|
||||
0.000000 20.000000
|
||||
}
|
||||
List TimeEventSizeX
|
||||
{
|
||||
0.000000 72.000000
|
||||
}
|
||||
List TimeEventSizeY
|
||||
{
|
||||
0.000000 48.000000
|
||||
}
|
||||
}
|
||||
|
||||
Group ParticleProperty
|
||||
{
|
||||
SrcBlendType 5
|
||||
DestBlendType 6
|
||||
ColorOperationType 4
|
||||
BillboardType 1
|
||||
RotationType 0
|
||||
RotationSpeed 0.000000
|
||||
RotationRandomStartingBegin 0
|
||||
RotationRandomStartingEnd 0
|
||||
|
||||
AttachEnable 1
|
||||
StretchEnable 0
|
||||
|
||||
TexAniType 1
|
||||
TexAniDelay 0.040000
|
||||
TexAniRandomStartFrameEnable 0
|
||||
EnableFrustum 0
|
||||
|
||||
List TimeEventGravity
|
||||
{
|
||||
}
|
||||
List TimeEventAirResistance
|
||||
{
|
||||
}
|
||||
List TimeEventScaleX
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventScaleY
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventColorRed
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventColorGreen
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventColorBlue
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventAlpha
|
||||
{
|
||||
0.000000 1.000000
|
||||
0.405128 1.000000
|
||||
0.505128 0.000000
|
||||
}
|
||||
List TimeEventRotation
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TextureFiles
|
||||
{
|
||||
"D:\Ymir Work\effect\pet\halloween_2022_coffin_bat_01.dds"
|
||||
"D:\Ymir Work\effect\pet\halloween_2022_coffin_bat_02.dds"
|
||||
"D:\Ymir Work\effect\pet\halloween_2022_coffin_bat_03.dds"
|
||||
}
|
||||
}
|
||||
}
|
||||
Group Particle
|
||||
{
|
||||
StartTime 0.000000
|
||||
List TimeEventPosition
|
||||
{
|
||||
0.000000 "MOVING_TYPE_DIRECT" 0.000000 0.000000 0.000000
|
||||
}
|
||||
|
||||
StaticRotation 0.000000 0.000000 0.000000
|
||||
Group EmitterProperty
|
||||
{
|
||||
MaxEmissionCount 1
|
||||
|
||||
CycleLength 20.000000
|
||||
CycleLoopEnable 1
|
||||
LoopCount 2
|
||||
|
||||
EmitterShape 0
|
||||
EmitterAdvancedType 0
|
||||
EmitterEmitFromEdgeFlag 0
|
||||
EmittingDirection 0.100000 0.000000 0.050000
|
||||
|
||||
List TimeEventEmittingSize
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TimeEventEmittingAngularVelocity
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TimeEventEmittingDirectionX
|
||||
{
|
||||
0.000000 50.000000
|
||||
}
|
||||
List TimeEventEmittingDirectionY
|
||||
{
|
||||
0.000000 50.000000
|
||||
0.100000 20.000000
|
||||
}
|
||||
List TimeEventEmittingDirectionZ
|
||||
{
|
||||
0.000000 10.000000
|
||||
0.050000 100.000000
|
||||
0.100000 1000.000000
|
||||
}
|
||||
List TimeEventEmittingVelocity
|
||||
{
|
||||
0.000000 5.000000
|
||||
0.083429 15.000000
|
||||
}
|
||||
List TimeEventEmissionCountPerSecond
|
||||
{
|
||||
0.000000 1000.000000
|
||||
}
|
||||
List TimeEventLifeTime
|
||||
{
|
||||
0.000000 20.000000
|
||||
}
|
||||
List TimeEventSizeX
|
||||
{
|
||||
0.000000 60.000000
|
||||
}
|
||||
List TimeEventSizeY
|
||||
{
|
||||
0.000000 36.000000
|
||||
}
|
||||
}
|
||||
|
||||
Group ParticleProperty
|
||||
{
|
||||
SrcBlendType 5
|
||||
DestBlendType 6
|
||||
ColorOperationType 4
|
||||
BillboardType 1
|
||||
RotationType 0
|
||||
RotationSpeed 0.000000
|
||||
RotationRandomStartingBegin 0
|
||||
RotationRandomStartingEnd 0
|
||||
|
||||
AttachEnable 1
|
||||
StretchEnable 0
|
||||
|
||||
TexAniType 1
|
||||
TexAniDelay 0.040000
|
||||
TexAniRandomStartFrameEnable 0
|
||||
EnableFrustum 0
|
||||
|
||||
List TimeEventGravity
|
||||
{
|
||||
}
|
||||
List TimeEventAirResistance
|
||||
{
|
||||
}
|
||||
List TimeEventScaleX
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventScaleY
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventColorRed
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventColorGreen
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventColorBlue
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventAlpha
|
||||
{
|
||||
0.000000 1.000000
|
||||
0.382051 1.000000
|
||||
0.500000 0.000000
|
||||
}
|
||||
List TimeEventRotation
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TextureFiles
|
||||
{
|
||||
"D:\Ymir Work\effect\pet\halloween_2022_coffin_bat_01.dds"
|
||||
"D:\Ymir Work\effect\pet\halloween_2022_coffin_bat_02.dds"
|
||||
"D:\Ymir Work\effect\pet\halloween_2022_coffin_bat_03.dds"
|
||||
}
|
||||
}
|
||||
}
|
||||
Group Particle
|
||||
{
|
||||
StartTime 0.000000
|
||||
List TimeEventPosition
|
||||
{
|
||||
0.000000 "MOVING_TYPE_DIRECT" 0.000000 0.000000 0.000000
|
||||
0.216000 "MOVING_TYPE_DIRECT" 0.000000 -20.000000 20.000000
|
||||
}
|
||||
|
||||
StaticRotation 0.000000 0.000000 0.000000
|
||||
Group EmitterProperty
|
||||
{
|
||||
MaxEmissionCount 30
|
||||
|
||||
CycleLength 20.000000
|
||||
CycleLoopEnable 1
|
||||
LoopCount 2
|
||||
|
||||
EmitterShape 3
|
||||
EmitterAdvancedType 1
|
||||
EmittingRadius 80.000000
|
||||
EmitterEmitFromEdgeFlag 0
|
||||
EmittingDirection 0.010000 0.010000 0.010000
|
||||
|
||||
List TimeEventEmittingSize
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TimeEventEmittingAngularVelocity
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TimeEventEmittingDirectionX
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TimeEventEmittingDirectionY
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TimeEventEmittingDirectionZ
|
||||
{
|
||||
0.000000 20.000000
|
||||
}
|
||||
List TimeEventEmittingVelocity
|
||||
{
|
||||
0.000000 1.000000
|
||||
}
|
||||
List TimeEventEmissionCountPerSecond
|
||||
{
|
||||
0.000000 1000.000000
|
||||
}
|
||||
List TimeEventLifeTime
|
||||
{
|
||||
0.000000 20.000000
|
||||
}
|
||||
List TimeEventSizeX
|
||||
{
|
||||
0.000000 128.000000
|
||||
}
|
||||
List TimeEventSizeY
|
||||
{
|
||||
0.000000 128.000000
|
||||
}
|
||||
}
|
||||
|
||||
Group ParticleProperty
|
||||
{
|
||||
SrcBlendType 5
|
||||
DestBlendType 6
|
||||
ColorOperationType 4
|
||||
BillboardType 1
|
||||
RotationType 4
|
||||
RotationSpeed 56.000000
|
||||
RotationRandomStartingBegin 360
|
||||
RotationRandomStartingEnd 0
|
||||
|
||||
AttachEnable 0
|
||||
StretchEnable 0
|
||||
|
||||
TexAniType 0
|
||||
TexAniDelay 0.040000
|
||||
TexAniRandomStartFrameEnable 0
|
||||
EnableFrustum 0
|
||||
|
||||
List TimeEventGravity
|
||||
{
|
||||
}
|
||||
List TimeEventAirResistance
|
||||
{
|
||||
0.000000 0.000000
|
||||
0.034286 0.100000
|
||||
}
|
||||
List TimeEventScaleX
|
||||
{
|
||||
0.000000 0.500000
|
||||
1.000000 1.000000
|
||||
}
|
||||
List TimeEventScaleY
|
||||
{
|
||||
0.000000 0.500000
|
||||
1.000000 1.000000
|
||||
}
|
||||
List TimeEventColorRed
|
||||
{
|
||||
0.000000 0.819608
|
||||
}
|
||||
List TimeEventColorGreen
|
||||
{
|
||||
0.000000 0.780392
|
||||
}
|
||||
List TimeEventColorBlue
|
||||
{
|
||||
0.000000 0.682353
|
||||
}
|
||||
List TimeEventAlpha
|
||||
{
|
||||
0.000000 1.000000
|
||||
0.050000 0.000000
|
||||
}
|
||||
List TimeEventRotation
|
||||
{
|
||||
0.000000 0.000000
|
||||
}
|
||||
List TextureFiles
|
||||
{
|
||||
"D:\Ymir Work\effect\monster2\smoke_dirt1.dds"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
BoundingSphereRadius 400.000000
|
||||
BoundingSpherePosition -180.000000 -60.000000 650.000000
|
||||
|
||||
Group Mesh
|
||||
{
|
||||
StartTime 0.000000
|
||||
List TimeEventPosition
|
||||
{
|
||||
0.000000 "MOVING_TYPE_DIRECT" 0.000000 0.000000 0.000000
|
||||
}
|
||||
|
||||
MeshFileName "mushroomA_01.mde"
|
||||
MeshAnimationLoopEnable 1
|
||||
MeshAnimationLoopCount 0
|
||||
MeshAnimationFrameDelay 0.020000
|
||||
|
||||
MeshElementCount 1
|
||||
Group MeshElement00
|
||||
{
|
||||
BillboardType 0
|
||||
BlendingEnable 1
|
||||
BlendingSrcType 5
|
||||
BlendingDestType 2
|
||||
|
||||
TextureAnimationLoopEnable 1
|
||||
TextureAnimationFrameDelay 0.020000
|
||||
TextureAnimationStartFrame 0
|
||||
|
||||
ColorOperationType 5
|
||||
ColorFactor 0.168627 0.643137 0.360784 1.000000
|
||||
List TimeEventAlpha
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
BoundingSphereRadius 500.000000
|
||||
BoundingSpherePosition 100.000000 0.000000 550.000000
|
||||
|
||||
Group Mesh
|
||||
{
|
||||
StartTime 0.000000
|
||||
List TimeEventPosition
|
||||
{
|
||||
0.000000 "MOVING_TYPE_DIRECT" 0.000000 0.000000 0.000000
|
||||
}
|
||||
|
||||
MeshFileName "mushroomA_03.mde"
|
||||
MeshAnimationLoopEnable 1
|
||||
MeshAnimationLoopCount 0
|
||||
MeshAnimationFrameDelay 0.020000
|
||||
|
||||
MeshElementCount 1
|
||||
Group MeshElement00
|
||||
{
|
||||
BillboardType 0
|
||||
BlendingEnable 1
|
||||
BlendingSrcType 5
|
||||
BlendingDestType 2
|
||||
|
||||
TextureAnimationLoopEnable 1
|
||||
TextureAnimationFrameDelay 0.020000
|
||||
TextureAnimationStartFrame 0
|
||||
|
||||
ColorOperationType 5
|
||||
ColorFactor 0.184314 0.643137 0.462745 1.000000
|
||||
List TimeEventAlpha
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
BoundingSphereRadius 330.000000
|
||||
BoundingSpherePosition -160.000000 0.000000 370.000000
|
||||
|
||||
Group Mesh
|
||||
{
|
||||
StartTime 0.000000
|
||||
List TimeEventPosition
|
||||
{
|
||||
0.000000 "MOVING_TYPE_DIRECT" 0.000000 0.000000 0.000000
|
||||
}
|
||||
|
||||
MeshFileName "mushroomA_04.mde"
|
||||
MeshAnimationLoopEnable 1
|
||||
MeshAnimationLoopCount 0
|
||||
MeshAnimationFrameDelay 0.020000
|
||||
|
||||
MeshElementCount 1
|
||||
Group MeshElement00
|
||||
{
|
||||
BillboardType 0
|
||||
BlendingEnable 1
|
||||
BlendingSrcType 5
|
||||
BlendingDestType 2
|
||||
|
||||
TextureAnimationLoopEnable 1
|
||||
TextureAnimationFrameDelay 0.020000
|
||||
TextureAnimationStartFrame 0
|
||||
|
||||
ColorOperationType 5
|
||||
ColorFactor 0.200000 0.521569 0.227451 1.000000
|
||||
List TimeEventAlpha
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
BoundingSphereRadius 2500.000000
|
||||
BoundingSpherePosition 0.000000 0.000000 1800.000000
|
||||
|
||||
Group Mesh
|
||||
{
|
||||
StartTime 0.000000
|
||||
List TimeEventPosition
|
||||
{
|
||||
0.000000 "MOVING_TYPE_DIRECT" 0.000000 0.000000 0.000000
|
||||
}
|
||||
|
||||
MeshFileName "turtle_statue_tree_roof_light01.mde"
|
||||
MeshAnimationLoopEnable 1
|
||||
MeshAnimationLoopCount 0
|
||||
MeshAnimationFrameDelay 0.020000
|
||||
|
||||
MeshElementCount 1
|
||||
Group MeshElement00
|
||||
{
|
||||
BillboardType 0
|
||||
BlendingEnable 1
|
||||
BlendingSrcType 5
|
||||
BlendingDestType 2
|
||||
|
||||
TextureAnimationLoopEnable 1
|
||||
TextureAnimationFrameDelay 0.020000
|
||||
TextureAnimationStartFrame 0
|
||||
|
||||
ColorOperationType 5
|
||||
ColorFactor 0.772549 0.733333 0.188235 1.000000
|
||||
List TimeEventAlpha
|
||||
{
|
||||
0.046667 0.577320
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
assets/Effect/ymir work/effect/etc/compete/ready.dds
Normal file
BIN
assets/Effect/ymir work/effect/etc/compete/ready.dds
Normal file
Binary file not shown.
BIN
assets/Effect/ymir work/effect/monster2/smoke_dust.dds
Normal file
BIN
assets/Effect/ymir work/effect/monster2/smoke_dust.dds
Normal file
Binary file not shown.
@@ -1,5 +1,4 @@
|
||||
ScriptType MotionData
|
||||
|
||||
MotionFileName "D:\Ymir Work\monster\orc_lord\32_1.GR2"
|
||||
MotionFileName "D:\Ymir Work\monster\orc_lord\30_1.GR2"
|
||||
MotionDuration 0.833333
|
||||
|
||||
|
||||
BIN
assets/Sound/sound/common/walk_grass_n.wav
Normal file
BIN
assets/Sound/sound/common/walk_grass_n.wav
Normal file
Binary file not shown.
BIN
assets/Sound/sound/monster/bou/bou_swing1.wav
Normal file
BIN
assets/Sound/sound/monster/bou/bou_swing1.wav
Normal file
Binary file not shown.
BIN
assets/Sound/sound/monster/chuhen/club_attack.wav
Normal file
BIN
assets/Sound/sound/monster/chuhen/club_attack.wav
Normal file
Binary file not shown.
BIN
assets/Sound/sound/monster/gupae/gup_swing2.wav
Normal file
BIN
assets/Sound/sound/monster/gupae/gup_swing2.wav
Normal file
Binary file not shown.
BIN
assets/Sound/sound/monster/gupae/gup_swing3.wav
Normal file
BIN
assets/Sound/sound/monster/gupae/gup_swing3.wav
Normal file
Binary file not shown.
BIN
assets/Sound/sound/monster/maenghwan/mah_swing1.wav
Normal file
BIN
assets/Sound/sound/monster/maenghwan/mah_swing1.wav
Normal file
Binary file not shown.
BIN
assets/Sound/sound/monster/misterious_diseased_boss/damage_1.wav
Normal file
BIN
assets/Sound/sound/monster/misterious_diseased_boss/damage_1.wav
Normal file
Binary file not shown.
BIN
assets/Sound/sound/monster/thief1/th1_act2.wav
Normal file
BIN
assets/Sound/sound/monster/thief1/th1_act2.wav
Normal file
Binary file not shown.
BIN
assets/Sound/sound/monster/thief1/th1_swing2.wav
Normal file
BIN
assets/Sound/sound/monster/thief1/th1_swing2.wav
Normal file
Binary file not shown.
BIN
assets/Sound/sound/monster/thief1/th1_swing3.wav
Normal file
BIN
assets/Sound/sound/monster/thief1/th1_swing3.wav
Normal file
Binary file not shown.
BIN
assets/Sound/sound/monster/thief2/th2_swing2.wav
Normal file
BIN
assets/Sound/sound/monster/thief2/th2_swing2.wav
Normal file
Binary file not shown.
BIN
assets/Sound/sound/monster/thief2/th2_swing3.wav
Normal file
BIN
assets/Sound/sound/monster/thief2/th2_swing3.wav
Normal file
Binary file not shown.
BIN
assets/Sound/sound/monster/thiefboss1/thb1_swing2.wav
Normal file
BIN
assets/Sound/sound/monster/thiefboss1/thb1_swing2.wav
Normal file
Binary file not shown.
BIN
assets/Sound/sound/monster/thiefboss2/thb2_swing2.wav
Normal file
BIN
assets/Sound/sound/monster/thiefboss2/thb2_swing2.wav
Normal file
Binary file not shown.
BIN
assets/Sound/sound/monster/thiefboss2/thb2_swing3.wav
Normal file
BIN
assets/Sound/sound/monster/thiefboss2/thb2_swing3.wav
Normal file
Binary file not shown.
BIN
assets/Sound/sound/monster/thiefboss3/thb3_swing2.wav
Normal file
BIN
assets/Sound/sound/monster/thiefboss3/thb3_swing2.wav
Normal file
Binary file not shown.
BIN
assets/Sound/sound/pc/sura/general/attack_1.wav
Normal file
BIN
assets/Sound/sound/pc/sura/general/attack_1.wav
Normal file
Binary file not shown.
BIN
assets/Sound/sound/pc/sura/general/attack_2.wav
Normal file
BIN
assets/Sound/sound/pc/sura/general/attack_2.wav
Normal file
Binary file not shown.
BIN
assets/Sound/sound/pc/sura/general/attack_3.wav
Normal file
BIN
assets/Sound/sound/pc/sura/general/attack_3.wav
Normal file
Binary file not shown.
BIN
assets/Sound/sound/pc/sura/general/swing_3.wav
Normal file
BIN
assets/Sound/sound/pc/sura/general/swing_3.wav
Normal file
Binary file not shown.
BIN
assets/Sound/sound/pc/sura/skill/horse_splash.wav
Normal file
BIN
assets/Sound/sound/pc/sura/skill/horse_splash.wav
Normal file
Binary file not shown.
Binary file not shown.
@@ -55,6 +55,157 @@ from _weakref import proxy
|
||||
SCREENSHOT_CWDSAVE = False
|
||||
SCREENSHOT_DIR = None
|
||||
|
||||
|
||||
def _WriteHeadlessMapTrace(message):
|
||||
if os.environ.get("M2_HEADLESS_SCENARIO", "").strip().lower() != "map_load":
|
||||
return
|
||||
|
||||
try:
|
||||
open("log/headless_map_load_trace.txt", "a").write("%s\n" % message)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
def _GetHeadlessScenario():
|
||||
return os.environ.get("M2_HEADLESS_SCENARIO", "").strip().lower()
|
||||
|
||||
|
||||
def _WriteHeadlessTrace(message):
|
||||
scenario = _GetHeadlessScenario()
|
||||
if scenario == "map_load":
|
||||
tracePath = "log/headless_map_load_trace.txt"
|
||||
elif scenario == "gm_teleport":
|
||||
tracePath = "log/headless_gm_teleport_trace.txt"
|
||||
else:
|
||||
return
|
||||
|
||||
try:
|
||||
open(tracePath, "a").write("%s\n" % message)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
def _GetHeadlessFloat(name, default):
|
||||
try:
|
||||
return float(os.environ.get(name, str(default)))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _GetHeadlessWarpSteps():
|
||||
rawValue = os.environ.get("M2_HEADLESS_WARP_STEPS", "").strip()
|
||||
steps = []
|
||||
|
||||
if not rawValue:
|
||||
return steps
|
||||
|
||||
for rawStep in rawValue.split("|"):
|
||||
rawStep = rawStep.strip()
|
||||
if not rawStep:
|
||||
continue
|
||||
|
||||
parts = [part.strip() for part in rawStep.split(",")]
|
||||
if len(parts) != 3:
|
||||
_WriteHeadlessTrace("Invalid warp step=%s" % rawStep)
|
||||
continue
|
||||
|
||||
mapName = parts[0]
|
||||
|
||||
try:
|
||||
globalX = int(parts[1])
|
||||
globalY = int(parts[2])
|
||||
except:
|
||||
_WriteHeadlessTrace("Invalid warp coords step=%s" % rawStep)
|
||||
continue
|
||||
|
||||
steps.append({
|
||||
"map_name": mapName,
|
||||
"global_x": globalX,
|
||||
"global_y": globalY,
|
||||
})
|
||||
|
||||
return steps
|
||||
|
||||
|
||||
HEADLESS_GM_STATE = {
|
||||
"initialized": False,
|
||||
"steps": [],
|
||||
"index": 0,
|
||||
"phase": "idle",
|
||||
"command_at": 0.0,
|
||||
"deadline": 0.0,
|
||||
"quit_requested": False,
|
||||
}
|
||||
|
||||
|
||||
def _HeadlessGMEnabled():
|
||||
return _GetHeadlessScenario() == "gm_teleport"
|
||||
|
||||
|
||||
def _HeadlessGMCommandDelay():
|
||||
return _GetHeadlessFloat("M2_HEADLESS_COMMAND_DELAY", 5.0)
|
||||
|
||||
|
||||
def _HeadlessGMWarpTimeout():
|
||||
return _GetHeadlessFloat("M2_HEADLESS_WARP_TIMEOUT", 20.0)
|
||||
|
||||
|
||||
def _HeadlessGMSettleDelay():
|
||||
return _GetHeadlessFloat("M2_HEADLESS_SETTLE_DELAY", 2.0)
|
||||
|
||||
|
||||
def _HeadlessGMRequestQuit(message):
|
||||
global HEADLESS_GM_STATE
|
||||
|
||||
if message:
|
||||
_WriteHeadlessTrace(message)
|
||||
|
||||
if HEADLESS_GM_STATE.get("quit_requested"):
|
||||
return
|
||||
|
||||
HEADLESS_GM_STATE["quit_requested"] = True
|
||||
app.Exit()
|
||||
|
||||
|
||||
class _HeadlessDummy(object):
|
||||
def __call__(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def __getattr__(self, name):
|
||||
return self
|
||||
|
||||
def __nonzero__(self):
|
||||
return False
|
||||
|
||||
|
||||
_HEADLESS_DUMMY = _HeadlessDummy()
|
||||
|
||||
|
||||
class _HeadlessInterface(object):
|
||||
def MakeInterface(self):
|
||||
return None
|
||||
|
||||
def ShowDefaultWindows(self):
|
||||
return None
|
||||
|
||||
def HideAllWindows(self):
|
||||
return None
|
||||
|
||||
def Close(self):
|
||||
return None
|
||||
|
||||
def BUILD_OnUpdate(self):
|
||||
return None
|
||||
|
||||
def OpenWhisperDialog(self, *args):
|
||||
return None
|
||||
|
||||
def SetMapName(self, *args):
|
||||
return None
|
||||
|
||||
def __getattr__(self, name):
|
||||
return _HEADLESS_DUMMY
|
||||
|
||||
cameraDistance = 1550.0
|
||||
cameraPitch = 27.0
|
||||
cameraRotation = 0.0
|
||||
@@ -64,6 +215,7 @@ testAlignment = 0
|
||||
|
||||
class GameWindow(ui.ScriptWindow):
|
||||
def __init__(self, stream):
|
||||
_WriteHeadlessTrace("GameWindow.__init__ begin")
|
||||
ui.ScriptWindow.__init__(self, "GAME")
|
||||
self.SetWindowName("game")
|
||||
net.SetPhaseWindow(net.PHASE_WINDOW_GAME, self)
|
||||
@@ -72,6 +224,7 @@ class GameWindow(ui.ScriptWindow):
|
||||
self.quickSlotPageIndex = 0
|
||||
self.lastPKModeSendedTime = 0
|
||||
self.pressNumber = None
|
||||
self.headlessGmEnabled = _HeadlessGMEnabled()
|
||||
|
||||
self.guildWarQuestionDialog = None
|
||||
self.interface = None
|
||||
@@ -82,9 +235,16 @@ class GameWindow(ui.ScriptWindow):
|
||||
self.playerGauge = None
|
||||
|
||||
self.stream = stream
|
||||
if self.headlessGmEnabled:
|
||||
self.interface = _HeadlessInterface()
|
||||
_WriteHeadlessTrace("GameWindow.__init__ interface_headless")
|
||||
else:
|
||||
self.interface = interfaceModule.Interface()
|
||||
_WriteHeadlessTrace("GameWindow.__init__ interface")
|
||||
self.interface.MakeInterface()
|
||||
_WriteHeadlessTrace("GameWindow.__init__ interface_made")
|
||||
self.interface.ShowDefaultWindows()
|
||||
_WriteHeadlessTrace("GameWindow.__init__ default_windows")
|
||||
|
||||
self.curtain = uiPhaseCurtain.PhaseCurtain()
|
||||
self.curtain.speed = 0.03
|
||||
@@ -112,6 +272,7 @@ class GameWindow(ui.ScriptWindow):
|
||||
|
||||
self.__ServerCommand_Build()
|
||||
self.__ProcessPreservedServerCommand()
|
||||
_WriteHeadlessTrace("GameWindow.__init__ done")
|
||||
|
||||
def __del__(self):
|
||||
player.SetGameWindow(0)
|
||||
@@ -133,6 +294,10 @@ class GameWindow(ui.ScriptWindow):
|
||||
self.enableXMasBoom = False
|
||||
self.startTimeXMasBoom = 0.0
|
||||
self.indexXMasBoom = 0
|
||||
currentMap = background.GetCurrentMapName()
|
||||
_WriteHeadlessTrace("GameWindow.Open current_map=%s" % currentMap)
|
||||
if self.headlessGmEnabled:
|
||||
self.__HeadlessGMOnOpen(currentMap)
|
||||
|
||||
global cameraDistance, cameraPitch, cameraRotation, cameraHeight
|
||||
|
||||
@@ -220,6 +385,7 @@ class GameWindow(ui.ScriptWindow):
|
||||
self.currentCubeNPC = 0
|
||||
|
||||
def Close(self):
|
||||
_WriteHeadlessTrace("GameWindow.Close begin current_map=%s" % background.GetCurrentMapName())
|
||||
self.Hide()
|
||||
|
||||
global cameraDistance, cameraPitch, cameraRotation, cameraHeight
|
||||
@@ -298,6 +464,7 @@ class GameWindow(ui.ScriptWindow):
|
||||
app.HideCursor()
|
||||
|
||||
print("---------------------------------------------------------------------------- CLOSE GAME WINDOW")
|
||||
_WriteHeadlessTrace("GameWindow.Close end current_map=%s" % background.GetCurrentMapName())
|
||||
|
||||
def __BuildKeyDict(self):
|
||||
onPressKeyDict = {}
|
||||
@@ -856,6 +1023,7 @@ class GameWindow(ui.ScriptWindow):
|
||||
|
||||
# SHOW_LOCAL_MAP_NAME
|
||||
def ShowMapName(self, mapName, x, y):
|
||||
_WriteHeadlessTrace("ShowMapName map=%s x=%d y=%d" % (mapName, x, y))
|
||||
|
||||
if self.mapNameShower:
|
||||
self.mapNameShower.ShowMapName(mapName, x, y)
|
||||
@@ -1465,8 +1633,104 @@ class GameWindow(ui.ScriptWindow):
|
||||
def OnMouseMiddleButtonUp(self):
|
||||
player.SetMouseMiddleButtonState(player.MBS_CLICK)
|
||||
|
||||
def __HeadlessGMOnOpen(self, currentMap):
|
||||
global HEADLESS_GM_STATE
|
||||
|
||||
state = HEADLESS_GM_STATE
|
||||
now = app.GetTime()
|
||||
|
||||
if not state["initialized"]:
|
||||
state["steps"] = _GetHeadlessWarpSteps()
|
||||
state["index"] = 0
|
||||
state["phase"] = "waiting_command"
|
||||
state["command_at"] = now + _HeadlessGMCommandDelay()
|
||||
state["deadline"] = 0.0
|
||||
state["quit_requested"] = False
|
||||
state["initialized"] = True
|
||||
_WriteHeadlessTrace("GM ready current_map=%s steps=%d" % (currentMap, len(state["steps"])))
|
||||
|
||||
if not state["steps"]:
|
||||
state["phase"] = "failed"
|
||||
_HeadlessGMRequestQuit("No warp steps configured")
|
||||
return
|
||||
|
||||
if state["phase"] != "waiting_open":
|
||||
return
|
||||
|
||||
if state["index"] >= len(state["steps"]):
|
||||
state["phase"] = "success"
|
||||
_HeadlessGMRequestQuit("Scenario success current_map=%s" % currentMap)
|
||||
return
|
||||
|
||||
expectedMap = state["steps"][state["index"]]["map_name"]
|
||||
if currentMap != expectedMap:
|
||||
state["phase"] = "failed"
|
||||
_HeadlessGMRequestQuit("Warp open mismatch index=%d expected=%s current_map=%s" % (state["index"], expectedMap, currentMap))
|
||||
return
|
||||
|
||||
_WriteHeadlessTrace("Warp arrived index=%d map=%s" % (state["index"], currentMap))
|
||||
state["index"] += 1
|
||||
|
||||
if state["index"] >= len(state["steps"]):
|
||||
state["phase"] = "success"
|
||||
_HeadlessGMRequestQuit("Scenario success current_map=%s" % currentMap)
|
||||
return
|
||||
|
||||
state["phase"] = "settling"
|
||||
state["command_at"] = now + _HeadlessGMSettleDelay()
|
||||
state["deadline"] = 0.0
|
||||
|
||||
def __HeadlessGMOnUpdate(self):
|
||||
global HEADLESS_GM_STATE
|
||||
|
||||
if not self.headlessGmEnabled:
|
||||
return
|
||||
|
||||
state = HEADLESS_GM_STATE
|
||||
if not state["initialized"] or state["quit_requested"]:
|
||||
return
|
||||
|
||||
now = app.GetTime()
|
||||
|
||||
if state["phase"] == "waiting_command":
|
||||
if state["index"] >= len(state["steps"]):
|
||||
state["phase"] = "success"
|
||||
_HeadlessGMRequestQuit("Scenario success current_map=%s" % background.GetCurrentMapName())
|
||||
return
|
||||
|
||||
if now < state["command_at"]:
|
||||
return
|
||||
|
||||
if 0 == player.GetMainCharacterIndex():
|
||||
return
|
||||
|
||||
step = state["steps"][state["index"]]
|
||||
meterX = int(step["global_x"] / 100)
|
||||
meterY = int(step["global_y"] / 100)
|
||||
command = "/warp %d %d" % (meterX, meterY)
|
||||
_WriteHeadlessTrace("Warp send index=%d map=%s meter_x=%d meter_y=%d current_map=%s" % (state["index"], step["map_name"], meterX, meterY, background.GetCurrentMapName()))
|
||||
net.SendChatPacket(command)
|
||||
state["phase"] = "waiting_open"
|
||||
state["deadline"] = now + _HeadlessGMWarpTimeout()
|
||||
return
|
||||
|
||||
if state["phase"] == "settling":
|
||||
if now >= state["command_at"]:
|
||||
state["phase"] = "waiting_command"
|
||||
return
|
||||
|
||||
if state["phase"] == "waiting_open" and now > state["deadline"]:
|
||||
if state["index"] < len(state["steps"]):
|
||||
expectedMap = state["steps"][state["index"]]["map_name"]
|
||||
else:
|
||||
expectedMap = ""
|
||||
state["phase"] = "failed"
|
||||
_HeadlessGMRequestQuit("Warp timeout index=%d expected=%s current_map=%s" % (state["index"], expectedMap, background.GetCurrentMapName()))
|
||||
return
|
||||
|
||||
def OnUpdate(self):
|
||||
app.UpdateGame()
|
||||
self.__HeadlessGMOnUpdate()
|
||||
|
||||
if self.mapNameShower.IsShow():
|
||||
self.mapNameShower.Update()
|
||||
@@ -2229,4 +2493,3 @@ class GameWindow(ui.ScriptWindow):
|
||||
|
||||
def SkillClearCoolTime(self, slotIndex):
|
||||
self.interface.SkillClearCoolTime(slotIndex)
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import os
|
||||
|
||||
import ui
|
||||
import uiScriptLocale
|
||||
import net
|
||||
@@ -35,6 +37,25 @@ import uiOption
|
||||
import uiRestart
|
||||
####################################
|
||||
|
||||
|
||||
def _GetHeadlessScenario():
|
||||
return os.environ.get("M2_HEADLESS_SCENARIO", "").strip().lower()
|
||||
|
||||
|
||||
def _WriteHeadlessTrace(message):
|
||||
scenario = _GetHeadlessScenario()
|
||||
if scenario == "map_load":
|
||||
tracePath = "log/headless_map_load_trace.txt"
|
||||
elif scenario == "gm_teleport":
|
||||
tracePath = "log/headless_gm_teleport_trace.txt"
|
||||
else:
|
||||
return
|
||||
|
||||
try:
|
||||
open(tracePath, "a").write("%s\n" % message)
|
||||
except:
|
||||
pass
|
||||
|
||||
class LoadingWindow(ui.ScriptWindow):
|
||||
def __init__(self, stream):
|
||||
print("NEW LOADING WINDOW -------------------------------------------------------------------------------")
|
||||
@@ -218,6 +239,7 @@ class LoadingWindow(ui.ScriptWindow):
|
||||
try:
|
||||
runFunc()
|
||||
except:
|
||||
_WriteHeadlessTrace("LoadData failure step=%d" % progress)
|
||||
self.errMsg.Show()
|
||||
self.loadStepList=[]
|
||||
|
||||
@@ -302,7 +324,9 @@ class LoadingWindow(ui.ScriptWindow):
|
||||
emotion.RegisterEmotionIcons()
|
||||
|
||||
def __LoadMap(self):
|
||||
_WriteHeadlessTrace("LoadMap begin global_x=%d global_y=%d" % (self.playerX, self.playerY))
|
||||
net.Warp(self.playerX, self.playerY)
|
||||
_WriteHeadlessTrace("LoadMap current_map=%s" % background.GetCurrentMapName())
|
||||
|
||||
def __LoadSound(self):
|
||||
playerSettingModule.LoadGameData("SOUND")
|
||||
@@ -337,6 +361,7 @@ class LoadingWindow(ui.ScriptWindow):
|
||||
# END_OF_GUILD_BUILDING
|
||||
|
||||
def __StartGame(self):
|
||||
_WriteHeadlessTrace("StartGame begin current_map=%s" % background.GetCurrentMapName())
|
||||
background.SetViewDistanceSet(background.DISTANCE0, 25600)
|
||||
"""
|
||||
background.SetViewDistanceSet(background.DISTANCE1, 19200)
|
||||
@@ -349,6 +374,7 @@ class LoadingWindow(ui.ScriptWindow):
|
||||
app.SetGlobalCenterPosition(self.playerX, self.playerY)
|
||||
|
||||
net.StartGame()
|
||||
_WriteHeadlessTrace("StartGame queued current_map=%s" % background.GetCurrentMapName())
|
||||
|
||||
def _ReloadTitleNames():
|
||||
for i in range(len(localeInfo.TITLE_NAME_LIST)):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
import dbg
|
||||
import app
|
||||
import net
|
||||
@@ -18,6 +19,10 @@ import ime
|
||||
import uiScriptLocale
|
||||
import debugInfo
|
||||
|
||||
|
||||
def _AllowHeadlessLoginInfo():
|
||||
return os.environ.get("M2_HEADLESS_SCENARIO", "").strip().lower() == "gm_teleport"
|
||||
|
||||
# Multi-language hot-reload system
|
||||
from uilocaleselector import LocaleSelector
|
||||
|
||||
@@ -715,7 +720,7 @@ class LoginWindow(ui.ScriptWindow):
|
||||
|
||||
def __LoadLoginInfo(self, loginInfoFileName):
|
||||
# This should not work in production
|
||||
if not debugInfo.IsDebugMode():
|
||||
if not debugInfo.IsDebugMode() and not _AllowHeadlessLoginInfo():
|
||||
app.loggined = FALSE
|
||||
else:
|
||||
try:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
###################################################################################################
|
||||
# Network
|
||||
|
||||
import os
|
||||
|
||||
import app
|
||||
import chr
|
||||
import dbg
|
||||
@@ -18,6 +20,25 @@ import uiPhaseCurtain
|
||||
|
||||
import localeInfo
|
||||
|
||||
|
||||
def _GetHeadlessScenario():
|
||||
return os.environ.get("M2_HEADLESS_SCENARIO", "").strip().lower()
|
||||
|
||||
|
||||
def _WriteHeadlessTrace(message):
|
||||
scenario = _GetHeadlessScenario()
|
||||
if scenario == "map_load":
|
||||
tracePath = "log/headless_map_load_trace.txt"
|
||||
elif scenario == "gm_teleport":
|
||||
tracePath = "log/headless_gm_teleport_trace.txt"
|
||||
else:
|
||||
return
|
||||
|
||||
try:
|
||||
open(tracePath, "a").write("%s\n" % message)
|
||||
except:
|
||||
pass
|
||||
|
||||
class PopupDialog(ui.ScriptWindow):
|
||||
# MR-15: Multiline dialog messages
|
||||
BASE_HEIGHT = 105
|
||||
@@ -167,6 +188,7 @@ class MainStream(object):
|
||||
|
||||
if newPhaseWindow:
|
||||
newPhaseWindow.Open()
|
||||
_WriteHeadlessTrace("MainStream.ChangePhase opened=%s" % newPhaseWindow.__class__.__name__)
|
||||
|
||||
self.curPhaseWindow=newPhaseWindow
|
||||
|
||||
@@ -237,7 +259,7 @@ class MainStream(object):
|
||||
try:
|
||||
import introLoading
|
||||
loadingPhaseWindow=introLoading.LoadingWindow(self)
|
||||
loadingPhaseWindow.LoadData(x, y)
|
||||
loadingPhaseWindow.DEBUG_LoadData(x, y)
|
||||
self.SetPhaseWindow(loadingPhaseWindow)
|
||||
except:
|
||||
import exception
|
||||
@@ -256,8 +278,10 @@ class MainStream(object):
|
||||
def SetGamePhase(self):
|
||||
try:
|
||||
import game
|
||||
_WriteHeadlessTrace("MainStream.SetGamePhase begin current_map=%s" % background.GetCurrentMapName())
|
||||
self.popupWindow.Close()
|
||||
self.SetPhaseWindow(game.GameWindow(self))
|
||||
_WriteHeadlessTrace("MainStream.SetGamePhase queued current_map=%s" % background.GetCurrentMapName())
|
||||
except:
|
||||
raise
|
||||
import exception
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import os
|
||||
|
||||
import dbg
|
||||
import app
|
||||
import localeInfo
|
||||
@@ -14,6 +16,51 @@ import stringCommander
|
||||
|
||||
#bind_me(locals().values())
|
||||
|
||||
|
||||
def _GetHeadlessScenario():
|
||||
return os.environ.get("M2_HEADLESS_SCENARIO", "").strip().lower()
|
||||
|
||||
|
||||
def _WriteHeadlessTrace(message):
|
||||
scenario = _GetHeadlessScenario()
|
||||
if scenario == "map_load":
|
||||
tracePath = "log/headless_map_load_trace.txt"
|
||||
elif scenario == "gm_teleport":
|
||||
tracePath = "log/headless_gm_teleport_trace.txt"
|
||||
else:
|
||||
return
|
||||
|
||||
try:
|
||||
open(tracePath, "a").write("%s\n" % message)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
def _GetHeadlessInt(name, default):
|
||||
try:
|
||||
return int(os.environ.get(name, str(default)))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _SetInitialPhase(mainStream):
|
||||
scenario = _GetHeadlessScenario()
|
||||
if scenario == "map_load":
|
||||
mapName = os.environ.get("M2_HEADLESS_MAP_NAME", "").strip()
|
||||
globalX = _GetHeadlessInt("M2_HEADLESS_GLOBAL_X", 460800)
|
||||
globalY = _GetHeadlessInt("M2_HEADLESS_GLOBAL_Y", 960000)
|
||||
|
||||
_WriteHeadlessTrace("Scenario begin map=%s global_x=%d global_y=%d" % (mapName, globalX, globalY))
|
||||
mainStream.SetTestGamePhase(globalX, globalY)
|
||||
return
|
||||
|
||||
if scenario == "gm_teleport":
|
||||
_WriteHeadlessTrace("Scenario begin gm_teleport")
|
||||
mainStream.SetLoginPhase()
|
||||
return
|
||||
|
||||
mainStream.SetLoginPhase()
|
||||
|
||||
def RunApp():
|
||||
musicInfo.LoadLastPlayFieldMusic()
|
||||
|
||||
@@ -47,8 +94,7 @@ def RunApp():
|
||||
mainStream.Create()
|
||||
|
||||
#mainStream.SetLoadingPhase()
|
||||
|
||||
mainStream.SetLoginPhase()
|
||||
_SetInitialPhase(mainStream)
|
||||
#mainStream.SetSelectCharacterPhase()
|
||||
#mainStream.SetCreateCharacterPhase()
|
||||
#mainStream.SetSelectEmpirePhase()
|
||||
@@ -58,4 +104,3 @@ def RunApp():
|
||||
mainStream.Destroy()
|
||||
|
||||
RunApp()
|
||||
|
||||
|
||||
BIN
assets/sound2/sound/monster2/outlaw/fall.wav
Normal file
BIN
assets/sound2/sound/monster2/outlaw/fall.wav
Normal file
Binary file not shown.
BIN
assets/sound2/sound/pc2/assassin/bow/attack1.wav
Normal file
BIN
assets/sound2/sound/pc2/assassin/bow/attack1.wav
Normal file
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
ScriptType CharacterSoundInformation
|
||||
|
||||
SoundDataCount 2
|
||||
SoundData00 0.066000 "sound/pc2/assassin/dualhand_sword/combo7.wav"
|
||||
SoundData01 0.033000 "sound/common/swing/w_1h_c_2.wav"
|
||||
SoundData00 0.231000 "sound/pc2/assassin/dualhand_sword/combo4.wav"
|
||||
SoundData01 0.198000 "sound/common/swing/a_dh_c_4.wav"
|
||||
|
||||
71
docs/caddy-updates.conf
Normal file
71
docs/caddy-updates.conf
Normal file
@@ -0,0 +1,71 @@
|
||||
# Caddy snippet for updates.jakubkadlec.dev.
|
||||
#
|
||||
# Drop this into the main Caddyfile on the VPS (or include it from there).
|
||||
# Caddy already handles TLS via Let's Encrypt for the parent zone; this block
|
||||
# only adds a subdomain that serves the update manifest, detached signature,
|
||||
# and content-addressed blob store.
|
||||
#
|
||||
# Directory layout on disk (owned by the release operator, not Caddy):
|
||||
#
|
||||
# /var/www/updates.jakubkadlec.dev/
|
||||
# ├── manifest.json
|
||||
# ├── manifest.json.sig
|
||||
# ├── manifests/
|
||||
# │ └── 2026.04.14-1.json (archived historical manifests)
|
||||
# ├── files/
|
||||
# │ └── <hash[0:2]>/<hash> content-addressed blobs
|
||||
# └── launcher/ Velopack feed (populated by Velopack's own publish tool)
|
||||
#
|
||||
# Create with:
|
||||
# sudo mkdir -p /var/www/updates.jakubkadlec.dev/{files,manifests,launcher}
|
||||
# sudo chown -R mt2.jakubkadlec.dev:mt2.jakubkadlec.dev /var/www/updates.jakubkadlec.dev
|
||||
#
|
||||
# Then add this to Caddy and `sudo systemctl reload caddy`.
|
||||
|
||||
updates.jakubkadlec.dev {
|
||||
root * /var/www/updates.jakubkadlec.dev
|
||||
|
||||
# Allow clients to resume interrupted downloads via HTTP Range.
|
||||
# Caddy's file_server sets Accept-Ranges: bytes by default, so there's
|
||||
# nothing extra to configure for this — listed explicitly as a reminder.
|
||||
file_server {
|
||||
precompressed gzip br
|
||||
}
|
||||
|
||||
# Content-addressed blobs are immutable (the hash IS the file name), so we
|
||||
# can tell clients to cache them forever. A manifest update never rewrites
|
||||
# an existing blob.
|
||||
@blobs path /files/*
|
||||
header @blobs Cache-Control "public, max-age=31536000, immutable"
|
||||
|
||||
# The manifest and its signature must never be cached beyond a minute —
|
||||
# clients need to see new releases quickly, and stale caches would delay
|
||||
# rollouts. Short TTL, not zero, to absorb thundering herds on release.
|
||||
@manifest path /manifest.json /manifest.json.sig
|
||||
header @manifest Cache-Control "public, max-age=60, must-revalidate"
|
||||
|
||||
# Historical manifests are as immutable as blobs — named by version.
|
||||
@archive path /manifests/*
|
||||
header @archive Cache-Control "public, max-age=31536000, immutable"
|
||||
|
||||
# The Velopack feed (launcher self-update) is a separate tree managed by
|
||||
# Velopack's publishing tool. Same cache rules as the main manifest: short
|
||||
# TTL on the feed metadata, blobs are immutable.
|
||||
@velopack-feed path /launcher/RELEASES*
|
||||
header @velopack-feed Cache-Control "public, max-age=60, must-revalidate"
|
||||
|
||||
@velopack-blobs path /launcher/*.nupkg
|
||||
header @velopack-blobs Cache-Control "public, max-age=31536000, immutable"
|
||||
|
||||
# CORS is not needed — the launcher is a native app, not a browser — so
|
||||
# no Access-Control-Allow-Origin header. If a web changelog page ever needs
|
||||
# to fetch the manifest from the browser, revisit this.
|
||||
|
||||
# Deny directory listings; the launcher knows exactly which paths it wants.
|
||||
file_server browse off 2>/dev/null || file_server
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/updates.jakubkadlec.dev.access.log
|
||||
format json
|
||||
}
|
||||
}
|
||||
52
docs/examples/manifest-example.json
Normal file
52
docs/examples/manifest-example.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"version": "2026.04.14-1",
|
||||
"created_at": "2026-04-14T14:00:00Z",
|
||||
"previous": "2026.04.13-3",
|
||||
"notes": "synthetic example showing the manifest structure. a real manifest covers tens of thousands of files.",
|
||||
"launcher": {
|
||||
"path": "Metin2Launcher.exe",
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"size": 15728640,
|
||||
"platform": "windows"
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"path": "Metin2.exe",
|
||||
"sha256": "2653e87ecd8ba305b70a96098788478e8b60c125ec83bcd1307486eafb5c5289",
|
||||
"size": 27982848,
|
||||
"platform": "windows"
|
||||
},
|
||||
{
|
||||
"path": "assets/root/serverinfo.py",
|
||||
"sha256": "b564fef7e45326ff8ad1b188c49cd61d46af1b07de657689814526725919d5cb",
|
||||
"size": 9231
|
||||
},
|
||||
{
|
||||
"path": "config.exe",
|
||||
"sha256": "a79c4e0daef43ce3a2666a92774fc6a369718177e456cb209fabe02ca640bcc2",
|
||||
"size": 258048,
|
||||
"platform": "windows"
|
||||
},
|
||||
{
|
||||
"path": "pack/item.pck",
|
||||
"sha256": "7aa9d46724a921fecf5af14c10372d0d03922e92a4cace4b5c15c451416f36b7",
|
||||
"size": 128547328
|
||||
},
|
||||
{
|
||||
"path": "pack/locale.pck",
|
||||
"sha256": "3b9dfe45317a14fcb70a138c1b9d57d984fe130e833c4341deaaff93d615ac67",
|
||||
"size": 4587520
|
||||
},
|
||||
{
|
||||
"path": "pack/metin2_patch_easter1.pck",
|
||||
"sha256": "2dab6a06d317014782cbe417d68dd3365d1d8c7cc35daa465611ce2108547706",
|
||||
"size": 12345600,
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"path": "pack/uiscript.eix",
|
||||
"sha256": "79bd367b31882e52dfa902f62112f5d43831673ed548ebbd530e2f86dfa77d14",
|
||||
"size": 892416
|
||||
}
|
||||
]
|
||||
}
|
||||
91
docs/linux-wine.md
Normal file
91
docs/linux-wine.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Running the client on Linux with Wine
|
||||
|
||||
This is an interim path for playing and testing on Linux while a native Linux port is a longer-term goal. Wine runs the unmodified Windows build of `Metin2.exe` / `Metin2_Debug.exe` directly. Verified to reach the character selection screen on Fedora 41 with Wine 10 Staging; other modern distros should work the same.
|
||||
|
||||
Use this when you want to:
|
||||
|
||||
- Smoke-test the Windows binary without rebooting into Windows
|
||||
- Develop server-side with a live client connected from the same machine
|
||||
- Run a dev loop without owning a Windows install
|
||||
|
||||
## Requirements
|
||||
|
||||
- A recent Wine (10.x Staging tested, 9.x stable should work). Older than 8 may be rough on D3D9.
|
||||
- `winetricks` for installing MSVC runtime, D3DX9 helper DLLs, core fonts, and Tahoma
|
||||
- A copy of the client deploy folder (the one containing `Metin2.exe`, `Metin2_Debug.exe`, `assets/`, `pack/`, `bgm/`, `config/`, `log/`). The whole folder is ~4.3 GB.
|
||||
- ~7 GB free disk for the writable client copy plus the Wine prefix
|
||||
|
||||
On Fedora:
|
||||
|
||||
```bash
|
||||
sudo dnf install -y wine winetricks
|
||||
```
|
||||
|
||||
On Debian/Ubuntu (use the WineHQ repo for a modern version):
|
||||
|
||||
```bash
|
||||
sudo apt install -y wine winetricks
|
||||
```
|
||||
|
||||
## One-shot setup
|
||||
|
||||
The easiest way is the helper script in this repo:
|
||||
|
||||
```bash
|
||||
./scripts/setup-wine-prefix.sh /path/to/windows/client ~/metin-wine
|
||||
```
|
||||
|
||||
This will:
|
||||
|
||||
1. Copy the client folder to `~/metin-wine/client` (needs to be on a writable filesystem, so an NTFS read-only mount won't do).
|
||||
2. Create a fresh Wine prefix at `~/metin-wine/prefix`.
|
||||
3. Install `vcrun2022`, `d3dx9`, `corefonts`, and `tahoma` via winetricks.
|
||||
4. Print the launch command.
|
||||
|
||||
See the script itself for exact steps if you prefer to run them manually.
|
||||
|
||||
## Why Tahoma is required
|
||||
|
||||
The client hard-codes Tahoma as its UI font. On Windows this is invisible because Tahoma ships with the OS; on a fresh Wine prefix it's missing, and the result is that the login screen renders layouts and backgrounds correctly but **all text is invisible**. You can reach the server picker and character selection, you just can't read anything. Installing Tahoma via `winetricks tahoma` fixes it in one shot.
|
||||
|
||||
If the login screen looks right but has no readable text, this is what you're seeing.
|
||||
|
||||
## Launching
|
||||
|
||||
After setup, the launch command is just:
|
||||
|
||||
```bash
|
||||
cd ~/metin-wine/client
|
||||
WINEPREFIX=~/metin-wine/prefix wine Metin2.exe
|
||||
```
|
||||
|
||||
Use `Metin2_Debug.exe` instead of `Metin2.exe` if you want more verbose client-side logging via `OutputDebugString`. Wine will echo those to stderr when `WINEDEBUG` includes `+seh` or you pass `+outputdebugstring`. For normal play use `-all,+err`.
|
||||
|
||||
## Logs and debug output
|
||||
|
||||
Useful `WINEDEBUG` settings:
|
||||
|
||||
- `WINEDEBUG=-all,+err` — quiet, only real errors. Use this for normal play.
|
||||
- `WINEDEBUG=-all,+loaddll,+module,+err` — shows which DLLs Wine loads, handy when the client crashes early with a missing DLL.
|
||||
- `WINEDEBUG=-all,+err,+seh` — captures the client's own `OutputDebugString` calls via SEH, which is how metin2's internal logging surfaces. Very noisy but useful when diagnosing client-side issues ("CResource::Load file not exist X", "CPythonNonPlayer::LoadNonPlayerData", etc.).
|
||||
|
||||
Redirect to a file and grep the signal out of the noise:
|
||||
|
||||
```bash
|
||||
WINEDEBUG=-all,+err,+seh wine Metin2_Debug.exe >wine-run.log 2>&1
|
||||
grep -E 'OutputDebugString[AW] "' wine-run.log | sed 's/.*OutputDebugString[AW] //' | sort -u
|
||||
```
|
||||
|
||||
The client also writes its own logs to `log/` inside the client folder. Those are plain text and more readable than the Wine SEH traces.
|
||||
|
||||
## Known quirks
|
||||
|
||||
- **Wayland:** works via XWayland, no special config. If the window opens minimized or off-screen, `Alt+Tab` to find it.
|
||||
- **Read-only NTFS mount:** don't try to launch from a read-only mount of your Windows partition. The client creates and writes `log/`, `config/`, and cache files; on a read-only FS the launch will be confusing. Always copy to a writable location first. `setup-wine-prefix.sh` does this for you.
|
||||
- **DXVK render state warnings:** lines like `D3D9DeviceEx::SetRenderState: Unhandled render state 163` in the log are harmless. DXVK doesn't implement every legacy D3D9 render state, but the ones metin2 cares about all work.
|
||||
- **SEH dispatch spam:** `dispatch_exception code=4001000a` / `4001000c` are how Windows signals `OutputDebugStringW` / `OutputDebugStringA`. They're soft exceptions, not errors. They only show up if you enable `+seh` in `WINEDEBUG`.
|
||||
- **First launch is slower:** DXVK compiles its shader pipelines on first run and writes a state cache. Subsequent launches are noticeably faster.
|
||||
|
||||
## When to stop using Wine
|
||||
|
||||
This guide is for the interim. The longer-term plan is a native Linux build of the client with a free-software replacement for Granny2 animation runtime. Until that lands, Wine is the way.
|
||||
210
docs/runbook-caddy-updates.md
Normal file
210
docs/runbook-caddy-updates.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# Runbook — bring up updates.jakubkadlec.dev
|
||||
|
||||
Operator runbook for turning the update channel on. Does the following on the production VPS `mt2.jakubkadlec.dev`:
|
||||
|
||||
1. Creates the directory layout the update manager expects
|
||||
2. Adds the Caddy site block for `updates.jakubkadlec.dev`
|
||||
3. Validates the Caddy config before reloading
|
||||
4. Reloads Caddy so the new vhost serves HTTPS with a fresh Let's Encrypt cert
|
||||
5. Verifies the vhost is up from an external client
|
||||
|
||||
**Pre-requisites:**
|
||||
|
||||
- Root or `sudo` access on the VPS.
|
||||
- DNS: `updates.jakubkadlec.dev` already resolves to the VPS IP (verified 2026-04-14: `194.163.138.177`). If it stops resolving, fix DNS first.
|
||||
- Port 80 open from the public internet (Caddy uses it for the ACME HTTP-01 challenge). Already open because Caddy is serving other sites on 443.
|
||||
|
||||
**Estimated time:** 5 minutes, most of it waiting for LE cert issuance.
|
||||
|
||||
**Rollback:** every mutating step has an explicit rollback below. The safest rollback is to restore the backup Caddyfile and reload — Caddy will drop the new vhost and keep everything else running exactly as before.
|
||||
|
||||
## Step 1 — SSH to the VPS
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/metin mt2.jakubkadlec.dev@mt2.jakubkadlec.dev
|
||||
```
|
||||
|
||||
All following commands run as the `mt2.jakubkadlec.dev` user unless marked `sudo`.
|
||||
|
||||
## Step 2 — Create the directory layout
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/www/updates.jakubkadlec.dev/{files,manifests,launcher}
|
||||
sudo chown -R mt2.jakubkadlec.dev:mt2.jakubkadlec.dev /var/www/updates.jakubkadlec.dev
|
||||
sudo chmod -R 755 /var/www/updates.jakubkadlec.dev
|
||||
|
||||
# Drop a placeholder manifest so the vhost has something to serve during validation.
|
||||
# This file will be overwritten by the first real release.
|
||||
cat > /tmp/placeholder-manifest.json <<'EOF'
|
||||
{
|
||||
"version": "0.0.0-placeholder",
|
||||
"created_at": "2026-04-14T00:00:00Z",
|
||||
"notes": "placeholder — replace with the first real signed release",
|
||||
"launcher": {
|
||||
"path": "Metin2Launcher.exe",
|
||||
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"size": 0,
|
||||
"platform": "windows"
|
||||
},
|
||||
"files": []
|
||||
}
|
||||
EOF
|
||||
sudo mv /tmp/placeholder-manifest.json /var/www/updates.jakubkadlec.dev/manifest.json
|
||||
sudo chown mt2.jakubkadlec.dev:mt2.jakubkadlec.dev /var/www/updates.jakubkadlec.dev/manifest.json
|
||||
```
|
||||
|
||||
**Rollback for step 2:**
|
||||
|
||||
```bash
|
||||
sudo rm -rf /var/www/updates.jakubkadlec.dev
|
||||
```
|
||||
|
||||
Note that without a signed placeholder the launcher will refuse to launch, because the zero-hash signature won't verify. That's **by design** — the launcher treats signature failure as "server is lying" and blocks the game. The placeholder is only there to prove HTTPS works; the first real release will overwrite it with a properly signed manifest.
|
||||
|
||||
## Step 3 — Back up the current Caddyfile
|
||||
|
||||
```bash
|
||||
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.bak.$(date +%Y%m%d-%H%M%S)
|
||||
ls -la /etc/caddy/Caddyfile.bak.*
|
||||
```
|
||||
|
||||
**Rollback for step 3:** there's nothing to roll back — backup files are harmless.
|
||||
|
||||
## Step 4 — Append the new vhost block to Caddyfile
|
||||
|
||||
The block lives in the repo at `docs/caddy-updates.conf`. Copy its contents and append them to `/etc/caddy/Caddyfile`:
|
||||
|
||||
```bash
|
||||
# From your local machine, or by pulling the file onto the VPS:
|
||||
sudo tee -a /etc/caddy/Caddyfile < /path/to/docs/caddy-updates.conf
|
||||
```
|
||||
|
||||
Or, if you'd rather pull it from Gitea directly on the VPS:
|
||||
|
||||
```bash
|
||||
curl -sS -H "Authorization: token $(cat ~/.config/metin/gitea-token)" \
|
||||
"https://gitea.jakubkadlec.dev/api/v1/repos/metin-server/m2dev-client/raw/main/docs/caddy-updates.conf" \
|
||||
| sudo tee -a /etc/caddy/Caddyfile
|
||||
```
|
||||
|
||||
(Replace `main` with the PR branch if you want to test before merge.)
|
||||
|
||||
Open the Caddyfile and confirm the block is at the end with no mangled whitespace:
|
||||
|
||||
```bash
|
||||
sudo tail -80 /etc/caddy/Caddyfile
|
||||
```
|
||||
|
||||
**Rollback for step 4:**
|
||||
|
||||
```bash
|
||||
sudo cp /etc/caddy/Caddyfile.bak.<timestamp> /etc/caddy/Caddyfile
|
||||
```
|
||||
|
||||
## Step 5 — Validate the new Caddyfile
|
||||
|
||||
**Do not skip this.** A broken Caddyfile + reload would take every Caddy-served site down together.
|
||||
|
||||
```bash
|
||||
sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
|
||||
```
|
||||
|
||||
Expected output ends with `Valid configuration`. Any line starting with `error` means stop and roll back step 4:
|
||||
|
||||
```bash
|
||||
sudo cp /etc/caddy/Caddyfile.bak.<timestamp> /etc/caddy/Caddyfile
|
||||
```
|
||||
|
||||
## Step 6 — Reload Caddy
|
||||
|
||||
```bash
|
||||
sudo systemctl reload caddy
|
||||
sudo systemctl status caddy --no-pager
|
||||
```
|
||||
|
||||
`reload` is not `restart` — running connections are preserved and Caddy loads the new config in place. If something goes wrong Caddy keeps the old config active.
|
||||
|
||||
**If reload fails** (systemctl returns non-zero), run the validate step again and read `journalctl -u caddy -n 50` to see the exact error, then roll back step 4 and reload again.
|
||||
|
||||
**Rollback for step 6:** restoring the backup Caddyfile and reloading takes you back to the previous state:
|
||||
|
||||
```bash
|
||||
sudo cp /etc/caddy/Caddyfile.bak.<timestamp> /etc/caddy/Caddyfile
|
||||
sudo systemctl reload caddy
|
||||
```
|
||||
|
||||
## Step 7 — Wait for Let's Encrypt cert issuance
|
||||
|
||||
Caddy issues a cert for the new subdomain automatically via the HTTP-01 challenge. Usually takes under 30 seconds.
|
||||
|
||||
Watch Caddy's logs for the issuance event:
|
||||
|
||||
```bash
|
||||
sudo journalctl -u caddy -f
|
||||
```
|
||||
|
||||
Look for lines mentioning `updates.jakubkadlec.dev`, specifically `certificate obtained successfully`. Ctrl-C out once you see it.
|
||||
|
||||
**If it doesn't issue within 2 minutes**, one of:
|
||||
|
||||
- Port 80 is blocked — check `sudo ss -tlnp | grep ':80'` shows Caddy listening.
|
||||
- DNS hasn't propagated — check `dig updates.jakubkadlec.dev +short` matches the VPS IP.
|
||||
- Let's Encrypt rate limit — check `journalctl -u caddy` for `too many certificates`. Wait an hour and retry; don't hammer.
|
||||
|
||||
## Step 8 — Verify from an external client
|
||||
|
||||
From any machine that isn't the VPS:
|
||||
|
||||
```bash
|
||||
# Cert subject should contain updates.jakubkadlec.dev in SAN
|
||||
echo | openssl s_client -connect updates.jakubkadlec.dev:443 \
|
||||
-servername updates.jakubkadlec.dev 2>/dev/null \
|
||||
| openssl x509 -noout -text | grep -A1 "Subject Alternative Name"
|
||||
|
||||
# Manifest should return 200 with a short Cache-Control
|
||||
curl -I https://updates.jakubkadlec.dev/manifest.json
|
||||
|
||||
# Placeholder manifest body, pretty-printed
|
||||
curl -sS https://updates.jakubkadlec.dev/manifest.json | jq .
|
||||
```
|
||||
|
||||
Expected: SAN contains `updates.jakubkadlec.dev`, HTTP 200, `Cache-Control: public, max-age=60, must-revalidate`, body is the placeholder JSON from step 2.
|
||||
|
||||
If all three pass, the update channel is live and the launcher will accept fetches (though it will still refuse to apply the placeholder manifest because its signature is not valid — see step 2 note).
|
||||
|
||||
## Step 9 — Clean up old backups (optional, later)
|
||||
|
||||
Once the vhost has been live for a week without incident:
|
||||
|
||||
```bash
|
||||
# List backups older than 7 days
|
||||
sudo find /etc/caddy/Caddyfile.bak.* -mtime +7
|
||||
|
||||
# Remove them
|
||||
sudo find /etc/caddy/Caddyfile.bak.* -mtime +7 -delete
|
||||
```
|
||||
|
||||
## Post-runbook — What's next
|
||||
|
||||
- The first real release uses `scripts/make-manifest.py` + `scripts/sign-manifest.py` (both in this repo) to produce `manifest.json` + `manifest.json.sig`, then rsync them onto `/var/www/updates.jakubkadlec.dev/` along with the content-addressed blobs under `files/<hash[0:2]>/<hash>`.
|
||||
- The launcher binary's own self-update path (Velopack) needs a separate publish step (`vpk pack`) that populates `/var/www/updates.jakubkadlec.dev/launcher/`. That's its own runbook and not part of this one.
|
||||
|
||||
## If something goes catastrophically wrong
|
||||
|
||||
Caddy dies across the board → Gitea (`gitea.jakubkadlec.dev`) and any other served site are offline. System SSH on port 22 is independent of Caddy, so you can always reach the box.
|
||||
|
||||
Recovery:
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/metin mt2.jakubkadlec.dev@mt2.jakubkadlec.dev
|
||||
|
||||
# Restore the last known-good Caddyfile
|
||||
sudo ls -lt /etc/caddy/Caddyfile.bak.* | head -1
|
||||
sudo cp /etc/caddy/Caddyfile.bak.<most-recent> /etc/caddy/Caddyfile
|
||||
|
||||
sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
|
||||
sudo systemctl reload caddy || sudo systemctl restart caddy
|
||||
sudo systemctl status caddy --no-pager
|
||||
```
|
||||
|
||||
Gitea SSH remains on port 2222 the whole time; it's a separate process and does not share fate with Caddy.
|
||||
251
docs/update-manager.md
Normal file
251
docs/update-manager.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# Update manager — design
|
||||
|
||||
This is the design for how the Metin2 client gets updated after the player's first install. Scope covers the launcher, the server-side manifest, the publishing flow, and the security model. Implementation plan is at the bottom.
|
||||
|
||||
## Goals and constraints
|
||||
|
||||
- The **base install is large** (~4.3 GB of packs + binaries). Shipping it through the update channel is a non-goal; base install is a separate bundled download.
|
||||
- Releases can happen **as often as daily**. A small script change in a Python pack should not force players to re-download the full client.
|
||||
- The update must be **atomic from the player's point of view**: they end up either on the old version or on the new one, never on a half-patched client.
|
||||
- **Integrity matters**: a malicious or buggy mirror must not be able to ship tampered files.
|
||||
- **Offline fallback**: if the update server is unreachable, the launcher lets the player into the game with whatever they have.
|
||||
- The launcher is the **single entry point** the player runs. It owns update detection, download, integrity checks, self-update, and game launch.
|
||||
- Publishing is **manual for v1** (`make-release.sh` + rsync), automated via Gitea Actions once the flow is proven.
|
||||
|
||||
## High-level architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐ ┌──────────────────────────────┐
|
||||
│ Player machine │ HTTPS │ VPS (Caddy) │
|
||||
│ ├────────► │
|
||||
│ Launcher.exe │ │ updates.jakubkadlec.dev/ │
|
||||
│ ├─ fetch manifest │ │ manifest.json │
|
||||
│ ├─ verify Ed25519 signature │ │ manifest.json.sig │
|
||||
│ ├─ diff with local files │ │ files/<hash>/<hash> │
|
||||
│ ├─ download missing files │ │ │
|
||||
│ ├─ verify each sha256 │ └──────────────────────────────┘
|
||||
│ ├─ atomic move into place │
|
||||
│ ├─ self-update if needed │
|
||||
│ └─ launch Metin2.exe │
|
||||
│ │
|
||||
│ client/ │
|
||||
│ Metin2.exe │
|
||||
│ Metin2Launcher.exe │
|
||||
│ pack/*.pck assets/* ... │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
### Server-side layout
|
||||
|
||||
Served statically by Caddy from `/var/www/updates.jakubkadlec.dev/`:
|
||||
|
||||
```
|
||||
updates.jakubkadlec.dev/
|
||||
├── manifest.json ← current release manifest
|
||||
├── manifest.json.sig ← Ed25519 signature over manifest.json
|
||||
├── manifests/
|
||||
│ ├── 2026.04.14-1.json ← archived historical manifests
|
||||
│ ├── 2026.04.14-1.json.sig
|
||||
│ └── ...
|
||||
└── files/
|
||||
└── ab/
|
||||
└── abc123...def ← content-addressed blob, named after sha256
|
||||
```
|
||||
|
||||
**Content-addressed storage** means a file is named after its sha256. Two consequences:
|
||||
|
||||
- **Automatic deduplication** across releases: if `item.pck` is unchanged, the new manifest points at the same blob. Nothing is uploaded or stored twice.
|
||||
- **Atomic publishing**: upload new blobs first, then replace `manifest.json` last. A partially-uploaded release never causes an inconsistent client state, because the client never sees the new manifest until it's complete.
|
||||
|
||||
### Manifest
|
||||
|
||||
See [update-manifest.md](./update-manifest.md) for the formal schema. Summary:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "2026.04.14-1",
|
||||
"created_at": "2026-04-14T12:00:00Z",
|
||||
"previous": "2026.04.13-3",
|
||||
"launcher": {
|
||||
"path": "Metin2Launcher.exe",
|
||||
"sha256": "..."
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"path": "Metin2.exe",
|
||||
"sha256": "...",
|
||||
"size": 27982848,
|
||||
"platform": "windows",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"path": "pack/item.pck",
|
||||
"sha256": "...",
|
||||
"size": 128000000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `version` is date-based (`YYYY.MM.DD-N` where `N` is the daily counter). Human-readable, sortable, forgiving of multiple releases per day.
|
||||
- `previous` lets the launcher show a changelog chain and enables smarter diff strategies later.
|
||||
- `launcher` is called out separately because it needs special handling (self-update).
|
||||
- `platform` is `windows` by default; future native Linux build can use `linux` and the launcher filters by its own platform.
|
||||
- `required: true` files block game launch if missing; optional files (language packs, optional assets) are opportunistic.
|
||||
|
||||
### Security model
|
||||
|
||||
- A single **Ed25519 keypair** signs each manifest. Private key lives on the release machine only (never in any repo). Public key is compiled into the launcher binary.
|
||||
- Launcher **refuses to apply** a manifest whose signature doesn't verify against the baked-in public key. No fallback, no "accept this once" dialog.
|
||||
- **sha256 per file** catches storage or transport corruption. A file whose downloaded bytes don't match the manifest hash is discarded and retried.
|
||||
- **Key rotation** flow: ship a new launcher that knows both the old and new public keys, transition period of a week, then ship one that only knows the new key. Because the launcher itself is delivered through the same update channel, this is clean.
|
||||
- **Transport** is HTTPS via Caddy (Let's Encrypt already). Ed25519 signing is defense-in-depth against compromised CDN / MITM, not the primary trust mechanism.
|
||||
|
||||
### Client behavior
|
||||
|
||||
Launcher does, in order:
|
||||
|
||||
1. **Fetch** `manifest.json` and `manifest.json.sig` (HTTP GET, timeout 10 s).
|
||||
2. **Verify** signature. On failure: abort update, log, go to step 8.
|
||||
3. **Parse** manifest, filter `files[]` by matching `platform`.
|
||||
4. For each file:
|
||||
- **Hash** the local copy (if present). If sha256 matches, skip.
|
||||
- Otherwise **download** the blob from `files/<hash[0:2]>/<hash>` into `staging/<path>` using HTTP Range requests (to resume partial downloads from a prior interrupted run).
|
||||
- **Verify** downloaded bytes against manifest hash. Mismatch = delete staging file, mark file as failed.
|
||||
5. If any **required** file failed after N retries: abort update, log, go to step 8 (offline fallback). Optional files that failed are silently skipped.
|
||||
6. **Self-update check**: if `launcher.sha256` differs from our own running binary, write the new launcher to `Metin2Launcher.new.exe`, spawn a small **trampoline** that waits for our PID to exit, replaces `Metin2Launcher.exe` with `Metin2Launcher.new.exe`, then exits. We then exit ourselves; the trampoline is a tiny native exe that lives alongside the launcher. See [Self-update details](#self-update-details).
|
||||
7. **Atomic apply**: for each non-launcher file, `MoveFileEx(staging, final, MOVEFILE_REPLACE_EXISTING)`. Keep a small manifest of moved paths so we can roll back on failure.
|
||||
8. **Launch**: `CreateProcess("Metin2.exe", ...)` with the current working directory at the client root. Exit the launcher once the game process has established itself.
|
||||
|
||||
### Self-update details
|
||||
|
||||
We do not implement self-update from scratch. The launcher embeds **[Velopack](https://github.com/velopack/velopack)** (MIT, Rust+.NET, actively maintained), which handles:
|
||||
|
||||
- Atomic replacement of the running launcher binary (stable install path, unlike legacy Squirrel)
|
||||
- Delta patches between launcher versions
|
||||
- Authenticode signature verification
|
||||
- Antivirus / firewall friendliness (no UAC prompt, no path churn)
|
||||
- ~2s update + relaunch
|
||||
|
||||
Velopack is used **only for the launcher binary itself**, which is small (~15 MB). The 4 GB game assets are handled by our own patcher code — Velopack is explicitly not designed for payloads that large.
|
||||
|
||||
Practical shape: at launcher startup we call `VelopackApp.Build().Run()`, then later `UpdateManager.CheckForUpdatesAsync()` against a separate Velopack release feed that lives alongside our asset manifest (e.g. `updates.jakubkadlec.dev/launcher/`). If a new launcher version is available, Velopack downloads it in the background and applies it on next restart. The asset update (sha256 manifest walk) runs unconditionally regardless of whether the launcher itself is updating.
|
||||
|
||||
The fallback path if Velopack ever fails — rename-before-replace plus a small `launcher-update.exe` trampoline — is documented but not implemented in the MVP. Velopack has been stable enough in production for us to start without it.
|
||||
|
||||
### Offline fallback
|
||||
|
||||
- If step 1 times out or returns non-2xx, launcher logs the failure and goes straight to step 8. The player gets into the game with whatever local version they already have.
|
||||
- If signature verification (step 2) fails, launcher does **not** fall back silently — it shows an error and refuses to launch, because "the server is lying to me" is more dangerous than "the server is down". This is the one case where we stop the player.
|
||||
- If the game server is down but the update server is up, that's the server runtime team's problem; the launcher is still successful.
|
||||
|
||||
### Directory layout on the player's machine
|
||||
|
||||
```
|
||||
client/
|
||||
├── Metin2Launcher.exe ← self-updating launcher, the player's entry point
|
||||
├── Metin2.exe ← managed by the launcher
|
||||
├── Metin2Launcher.exe.old ← previous launcher, kept for rollback (deleted after 1 successful run)
|
||||
├── Metin2.exe.old ← same for Metin2.exe
|
||||
├── pack/
|
||||
├── assets/
|
||||
├── config/
|
||||
├── log/
|
||||
└── .updates/
|
||||
├── current-manifest.json ← the manifest we're currently on
|
||||
├── staging/ ← download staging area, cleared after successful apply
|
||||
└── launcher.log ← launcher's own log
|
||||
```
|
||||
|
||||
Files under `.updates/` are created by the launcher. The user shouldn't touch them and we ship a `.gitignore` so they don't end up in any accidental archive.
|
||||
|
||||
## Publishing flow (v1, manual)
|
||||
|
||||
1. On a trusted machine (not random laptop), with the private signing key present:
|
||||
```bash
|
||||
./scripts/make-release.sh --version 2026.04.14-1 --source /path/to/fresh/client
|
||||
```
|
||||
2. The script walks the client directory, computes sha256 for each file, writes a `manifest.json`, signs it, and produces a release directory `release/2026.04.14-1/` containing the manifest, its signature, and only the new blobs (ones not already present on the server).
|
||||
3. Human review: diff the new manifest against the previous one, sanity-check size and file count.
|
||||
4. `rsync` the release directory to the VPS:
|
||||
```bash
|
||||
rsync -av release/2026.04.14-1/ mt2.jakubkadlec.dev@mt2.jakubkadlec.dev:/var/www/updates.jakubkadlec.dev/
|
||||
```
|
||||
5. Verify from a second machine: `curl` the manifest, check signature, check a random blob.
|
||||
6. Tag the release in git.
|
||||
|
||||
Manual because v1 should let us feel the flow before we automate. After ~2 weeks of successful manual releases, wire it into Gitea Actions.
|
||||
|
||||
## Publishing flow (v2, Gitea Actions)
|
||||
|
||||
Not implemented in MVP. Sketch:
|
||||
|
||||
- `m2dev-client-src` build artifact (Metin2.exe) and `m2dev-client` runtime content are combined by a release workflow.
|
||||
- The workflow runs `make-release.sh` using a signing key stored as a Gitea secret.
|
||||
- rsyncs to VPS via a deploy SSH key.
|
||||
- Opens a PR that updates `CHANGELOG.md` with the new version.
|
||||
|
||||
Trade-off: automation speed vs. the attack surface of a CI-held signing key. When we get there, we'll probably **sign offline** and let CI only publish pre-signed bundles.
|
||||
|
||||
## Failure modes and what we do about them
|
||||
|
||||
| Failure | Client behavior | Operator behavior |
|
||||
|---|---|---|
|
||||
| Update server 5xx | Launch game with current version | Investigate VPS / Caddy |
|
||||
| Update server returns invalid signature | Refuse to launch, show error | Rotate signing key, investigate source |
|
||||
| Partial download (network drop) | Resume on next run via Range | None, user retries |
|
||||
| Individual file hash mismatch after retries | Skip file if optional, abort if required | Investigate blob corruption |
|
||||
| Launcher self-update fails mid-replace | Rollback from `.old` copy, launch old launcher | Investigate, ship fixed launcher |
|
||||
| Player filesystem is full | Error out with actionable message ("free X MB, retry") | None |
|
||||
| Player has antivirus quarantining files | Error message naming the file that disappeared | Document, whitelist in launcher installer |
|
||||
| Someone ships a manifest with missing blobs | Launcher reports which files it can't fetch | Broken release, re-run publish |
|
||||
|
||||
## Prior art survey
|
||||
|
||||
Before writing code, a scan of the ecosystem for things to fork or copy. Bottom line: nothing in the Metin2 community is worth forking, but three external projects inform this design.
|
||||
|
||||
**Metin2-specific launchers**: the community reference is [Karbust/Metin2-Patcher-Electron](https://github.com/Karbust/Metin2-Patcher-Electron) (TypeScript/Electron, MIT, last push 2021). SHA256 per-file manifest, parallel HTTP downloads, two-zip deploy model. No signing, no delta, no self-update, dead deps. Worth skimming to understand what Metin2 server admins UX-expect. Everything else in the space (`VoidEngineCC/Metin2-Simple-C-Patcher`, `CeerdaN/metin2-patcher-electron`, `Cankira-BK/metin2-pvp-launcher`, ...) is either unlicensed, a toy, or abandoned. The ceiling of published prior art is "file list + sha256 + HTTP GET." We are already above it on paper.
|
||||
|
||||
**d1str4ught upstream**: no launcher, no patcher. The upstream distribution model is "clone the repo." Greenfield for us.
|
||||
|
||||
**General-purpose auto-updaters**:
|
||||
|
||||
- **[Velopack](https://github.com/velopack/velopack)** — the modern successor to Squirrel.Windows, by the same primary author. MIT, Rust+.NET, released regularly in 2025. Handles atomic binary replacement, delta patches, Authenticode, stable install paths. Used for the launcher self-update layer. Not used for game assets — not designed for 4 GB payloads.
|
||||
- Squirrel.Windows (legacy, unmaintained, known path-churn bugs), WiX Burn (wrong shape — chain installer, not update loop), NSIS (you reimplement everything), Rust `self_update` crate (single-file, no multi-artifact) — all rejected.
|
||||
|
||||
**Architectural reference**: **[runelite/launcher](https://github.com/runelite/launcher)** (Java, BSD-2). A tiny native launcher for a non-Steam game, with exactly the shape we want: bootstrap JSON → signed → list of artifacts with hashes → download missing → verify → launch. X.509 instead of Ed25519, same threat model. Before writing launcher code we read this end-to-end as the reference implementation; we do not copy code (wrong language), we copy structure.
|
||||
|
||||
**Ed25519 prior art in private-server game launchers**: [wowemulation-dev/wow-patcher](https://github.com/wowemulation-dev/wow-patcher) (Rust) replaces the WoW client's Ed25519 public key to redirect auth. Direct precedent for using Ed25519 in this role. Sparkle 2 on macOS has shipped Ed25519 appcast since 2021; same primitive, coarser per-release granularity.
|
||||
|
||||
**Manifest format**: the shape we have is loosely TUF-lite — one signed top-level JSON pointing at content-addressed blobs, without TUF's role separation. Full [TUF](https://theupdateframework.io/) is overkill for a 4-dev private-server project, but worth naming as the professional vocabulary. [OSTree](https://ostreedev.github.io/ostree/) implements exactly the content-addressed part at the filesystem level — a good read, too Linux-specific to reuse.
|
||||
|
||||
**Net take**: this design converges on the intersection of OSTree (content addressing), Sparkle (Ed25519 signing) and RuneLite launcher (bootstrap-signed-JSON → artifact list → verify → launch), with Velopack handling the self-update plumbing. Nothing novel, which is the point.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
Effort is real-days of Claude + review time from the team.
|
||||
|
||||
| # | Task | Effort | Output |
|
||||
|---|---|---|---|
|
||||
| 1 | This design doc, reviewed | 0.5 d | `docs/update-manager.md` |
|
||||
| 2 | Manifest schema spec | 0.5 d | `docs/update-manifest.md` |
|
||||
| 3 | `scripts/make-manifest.py` — walk dir, produce unsigned manifest | 1 d | Python script + docs |
|
||||
| 4 | Sign/verify script (Ed25519) | 0.5 d | Python + keygen docs |
|
||||
| 5 | Caddy config for `updates.jakubkadlec.dev` | 0.5 d | Caddyfile fragment + DNS note |
|
||||
| 6 | Launcher (C# .NET 8 self-contained, single-file) — skeleton + HTTP fetch + manifest parse + Ed25519 verify | 2 d | `launcher/` project |
|
||||
| 7 | Launcher — file diff + download + hash verify + atomic apply | 2 d | |
|
||||
| 8 | Launcher — Velopack integration for self-update | 0.5 d | |
|
||||
| 9 | End-to-end test (publish → client updates → launch) | 1 d | |
|
||||
| 10 | `scripts/make-release.sh` wiring it all together | 1 d | |
|
||||
| 11 | Docs: publisher runbook, player troubleshooting, threat model | 1 d | |
|
||||
|
||||
**MVP is items 1–10**, roughly **10 working days** of implementation. Review + integration + real-world hardening on top.
|
||||
|
||||
## Open questions left for the team
|
||||
|
||||
- **Launcher UI**: bare minimum (single window with a progress bar and "Play" button) vs. something nicer (changelog panel, news feed, image banner)? MVP is bare minimum; richer UI is a v2 concern.
|
||||
- **Localization**: manifest fields are English, but the launcher UI needs Czech (at least). Load strings from the client's existing `locale.pck`, or ship a separate small locale for the launcher? Lean toward the latter because launcher runs before the game and shouldn't depend on game assets.
|
||||
- **News feed**: optional. If yes, add a `news_url` field to the manifest and let the launcher fetch a small JSON blob. Nice-to-have.
|
||||
- **Analytics**: do we want to know how many players are on which version? Simple: launcher sends an HTTP POST with `{version, platform}` after successful update. Requires GDPR thought. Off by default, opt-in.
|
||||
|
||||
None of these block the MVP — they can be decided once the skeleton works.
|
||||
124
docs/update-manifest.md
Normal file
124
docs/update-manifest.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Update manifest — format specification
|
||||
|
||||
The update manifest is a JSON document describing a single release of the Metin2 client. It lives at `https://updates.jakubkadlec.dev/manifest.json` alongside its Ed25519 signature at `manifest.json.sig`.
|
||||
|
||||
See [update-manager.md](./update-manager.md) for the overall architecture this fits into.
|
||||
|
||||
## Top-level schema
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "2026.04.14-1",
|
||||
"created_at": "2026-04-14T12:00:00Z",
|
||||
"previous": "2026.04.13-3",
|
||||
"launcher": {
|
||||
"path": "Metin2Launcher.exe",
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"size": 15728640
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"path": "Metin2.exe",
|
||||
"sha256": "a1b2c3...",
|
||||
"size": 27982848,
|
||||
"platform": "windows",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Required top-level fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `version` | string | Release version. Format: `YYYY.MM.DD-N` where `N` is the 1-indexed daily counter. Sortable, human-readable, allows multiple releases per day. |
|
||||
| `created_at` | string | ISO 8601 timestamp in UTC with `Z` suffix. When the release was produced. |
|
||||
| `launcher` | object | The launcher binary. See below. Special because of self-update handling. |
|
||||
| `files` | array | The non-launcher files in the release. May be empty (launcher-only update). |
|
||||
|
||||
### Optional top-level fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `previous` | string | `version` of the manifest this release replaces. Omit for the first release ever. Used for changelog display and future delta-patch strategies. |
|
||||
| `notes` | string | Free-form release notes (Markdown). Displayed by the launcher in the changelog panel. |
|
||||
| `min_launcher_version` | string | Refuse to apply this manifest if launcher's own version is older than this. Used when a manifest change requires a newer launcher. |
|
||||
|
||||
## File entry schema
|
||||
|
||||
```json
|
||||
{
|
||||
"path": "pack/item.pck",
|
||||
"sha256": "def456abc123...",
|
||||
"size": 128000000,
|
||||
"platform": "all",
|
||||
"required": true
|
||||
}
|
||||
```
|
||||
|
||||
### Required file fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `path` | string | Path relative to the client root, using forward slashes. No `..` segments. |
|
||||
| `sha256` | string | Lowercase hex sha256 of the file contents. |
|
||||
| `size` | integer | File size in bytes. Used for the progress bar and to detect truncated downloads before hashing. |
|
||||
|
||||
### Optional file fields
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `platform` | string | `"all"` | One of `"windows"`, `"linux"`, `"all"`. Launcher filters by its own platform. |
|
||||
| `required` | boolean | `true` | If `false`, a failed download for this file does not block the game launch. |
|
||||
| `executable` | boolean | `false` | On Unix-like systems, set the executable bit after applying. Ignored on Windows. |
|
||||
|
||||
## Launcher entry
|
||||
|
||||
The `launcher` top-level object has the same fields as a file entry, but is called out separately because the launcher is a privileged file:
|
||||
|
||||
- The launcher replaces itself via **rename-before-replace**, not normal atomic move.
|
||||
- The launcher is **always required**; if it fails to update, the launcher does not launch the game, to avoid a broken loop where the player is running a buggy launcher that can't fix itself.
|
||||
- The launcher is **never** listed in the `files` array.
|
||||
|
||||
## Signing
|
||||
|
||||
`manifest.json.sig` is the raw Ed25519 signature over the literal bytes of `manifest.json`, in detached form. The public key is compiled into the launcher binary. Signing and verification use the standard Ed25519 algorithm (RFC 8032), no prehashing.
|
||||
|
||||
Example verification in Python:
|
||||
|
||||
```python
|
||||
import json
|
||||
from nacl.signing import VerifyKey
|
||||
|
||||
with open("manifest.json", "rb") as f:
|
||||
manifest_bytes = f.read()
|
||||
with open("manifest.json.sig", "rb") as f:
|
||||
sig = f.read()
|
||||
|
||||
VerifyKey(bytes.fromhex(PUBLIC_KEY_HEX)).verify(manifest_bytes, sig)
|
||||
```
|
||||
|
||||
In C# with `System.Security.Cryptography` (.NET 8+) or BouncyCastle.
|
||||
|
||||
## Canonical JSON
|
||||
|
||||
To keep signatures stable across trivial reformatting:
|
||||
|
||||
- Top-level keys appear in the order `version, created_at, previous, notes, min_launcher_version, launcher, files`.
|
||||
- Within the `files` array, entries are **sorted by `path`** lexicographically.
|
||||
- Within a file object, keys appear in the order `path, sha256, size, platform, required, executable`.
|
||||
- JSON is pretty-printed with **2-space indentation**, **LF line endings**, final newline.
|
||||
- Strings use the shortest valid JSON escapes (no `\u00XX` for printable ASCII).
|
||||
|
||||
`scripts/make-manifest.py` produces output in exactly this form. Do not re-serialize a manifest with a different JSON library before signing; the bytes must match.
|
||||
|
||||
## Versioning rules
|
||||
|
||||
- `version` strings are compared **as date + counter** (not as semver), via `(date, counter)` tuples.
|
||||
- A launcher always replaces its own installed version with the one from the latest manifest, regardless of whether the manifest's `version` is newer than the launcher's own version. There is no "downgrade protection" for the launcher itself because the server is the source of truth.
|
||||
- For the **client files** (not launcher), the launcher refuses to apply a manifest whose `version` is older than the locally-recorded `current-manifest.json` version. This prevents rollback attacks where a compromised CDN replays an old manifest to force players back onto an outdated client that had a known vulnerability.
|
||||
|
||||
## Example
|
||||
|
||||
See [examples/manifest-example.json](./examples/manifest-example.json) for a real manifest produced by `scripts/make-manifest.py` over the current dev client.
|
||||
220
scripts/make-manifest.py
Executable file
220
scripts/make-manifest.py
Executable file
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Walk a client directory and emit a release manifest.
|
||||
|
||||
Produces canonical-form JSON matching docs/update-manifest.md. Does not sign —
|
||||
pair with a separate signer that reads the manifest bytes verbatim and emits
|
||||
a detached Ed25519 signature.
|
||||
|
||||
Usage:
|
||||
make-manifest.py --source /path/to/client --version 2026.04.14-1 \\
|
||||
[--previous 2026.04.13-3] [--notes "bugfixes"] \\
|
||||
[--launcher Metin2Launcher.exe] [--out manifest.json]
|
||||
|
||||
The launcher path is treated specially: its entry appears at the top level
|
||||
under "launcher", not inside "files". Defaults to "Metin2Launcher.exe"; if
|
||||
that file does not exist in the source tree, the script refuses to run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# Files and directories that must never appear in a release manifest.
|
||||
# These are runtime artifacts, build outputs, or developer state.
|
||||
EXCLUDE_DIRS = {
|
||||
".git",
|
||||
".vs",
|
||||
".updates",
|
||||
"log",
|
||||
"__pycache__",
|
||||
}
|
||||
EXCLUDE_FILES = {
|
||||
".gitignore",
|
||||
".gitattributes",
|
||||
"desktop.ini",
|
||||
"Thumbs.db",
|
||||
".DS_Store",
|
||||
}
|
||||
EXCLUDE_SUFFIXES = {
|
||||
".pdb", # debug symbols
|
||||
".ilk", # MSVC incremental link
|
||||
".old", # rollback copies written by the launcher
|
||||
".log", # runtime logs, not release content
|
||||
".dxvk-cache", # DXVK shader cache, per-machine
|
||||
".swp",
|
||||
".tmp",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileEntry:
|
||||
path: str
|
||||
sha256: str
|
||||
size: int
|
||||
platform: str = "all"
|
||||
required: bool = True
|
||||
executable: bool = False
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
out = {
|
||||
"path": self.path,
|
||||
"sha256": self.sha256,
|
||||
"size": self.size,
|
||||
}
|
||||
if self.platform != "all":
|
||||
out["platform"] = self.platform
|
||||
if not self.required:
|
||||
out["required"] = False
|
||||
if self.executable:
|
||||
out["executable"] = True
|
||||
return out
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def should_skip(rel_path: Path) -> bool:
|
||||
for part in rel_path.parts:
|
||||
if part in EXCLUDE_DIRS:
|
||||
return True
|
||||
if rel_path.name in EXCLUDE_FILES:
|
||||
return True
|
||||
if rel_path.suffix in EXCLUDE_SUFFIXES:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def classify_platform(rel_path: Path) -> str:
|
||||
"""Very simple platform inference. Extend as native Linux build lands."""
|
||||
suffix = rel_path.suffix.lower()
|
||||
if suffix in {".exe", ".dll"}:
|
||||
return "windows"
|
||||
return "all"
|
||||
|
||||
|
||||
def walk_client(source: Path, launcher_rel: Path) -> tuple[FileEntry, list[FileEntry]]:
|
||||
launcher_entry: FileEntry | None = None
|
||||
files: list[FileEntry] = []
|
||||
|
||||
for path in sorted(source.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
rel = path.relative_to(source)
|
||||
if should_skip(rel):
|
||||
continue
|
||||
|
||||
entry = FileEntry(
|
||||
path=rel.as_posix(),
|
||||
sha256=sha256_file(path),
|
||||
size=path.stat().st_size,
|
||||
platform=classify_platform(rel),
|
||||
)
|
||||
|
||||
if rel == launcher_rel:
|
||||
launcher_entry = entry
|
||||
else:
|
||||
files.append(entry)
|
||||
|
||||
if launcher_entry is None:
|
||||
raise SystemExit(
|
||||
f"error: launcher file {launcher_rel} not found under {source}. "
|
||||
f"pass --launcher if the launcher is named differently, or create it."
|
||||
)
|
||||
|
||||
files.sort(key=lambda e: e.path)
|
||||
return launcher_entry, files
|
||||
|
||||
|
||||
def build_manifest(
|
||||
version: str,
|
||||
created_at: str,
|
||||
previous: str | None,
|
||||
notes: str | None,
|
||||
min_launcher_version: str | None,
|
||||
launcher: FileEntry,
|
||||
files: list[FileEntry],
|
||||
) -> dict:
|
||||
"""Assemble the manifest dict in canonical key order."""
|
||||
manifest: dict = {"version": version, "created_at": created_at}
|
||||
if previous is not None:
|
||||
manifest["previous"] = previous
|
||||
if notes is not None:
|
||||
manifest["notes"] = notes
|
||||
if min_launcher_version is not None:
|
||||
manifest["min_launcher_version"] = min_launcher_version
|
||||
manifest["launcher"] = launcher.to_dict()
|
||||
manifest["files"] = [f.to_dict() for f in files]
|
||||
return manifest
|
||||
|
||||
|
||||
def canonical_json(manifest: dict) -> bytes:
|
||||
text = json.dumps(manifest, indent=2, ensure_ascii=False)
|
||||
return (text + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--source", required=True, type=Path,
|
||||
help="Path to the client root directory")
|
||||
parser.add_argument("--version", required=True,
|
||||
help="Release version, e.g. 2026.04.14-1")
|
||||
parser.add_argument("--previous",
|
||||
help="Previous release version, if any")
|
||||
parser.add_argument("--notes",
|
||||
help="Release notes (Markdown allowed)")
|
||||
parser.add_argument("--min-launcher-version",
|
||||
help="Minimum launcher version that can apply this manifest")
|
||||
parser.add_argument("--launcher", default="Metin2Launcher.exe",
|
||||
help="Launcher filename relative to source (default: Metin2Launcher.exe)")
|
||||
parser.add_argument("--out", type=Path, default=Path("manifest.json"),
|
||||
help="Output file path (default: ./manifest.json)")
|
||||
parser.add_argument("--created-at",
|
||||
help="Override created_at timestamp (default: now, UTC). Useful for reproducible test runs.")
|
||||
args = parser.parse_args()
|
||||
|
||||
source: Path = args.source.resolve()
|
||||
if not source.is_dir():
|
||||
print(f"error: {source} is not a directory", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
created_at = args.created_at or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
launcher_rel = Path(args.launcher)
|
||||
|
||||
launcher, files = walk_client(source, launcher_rel)
|
||||
|
||||
manifest = build_manifest(
|
||||
version=args.version,
|
||||
created_at=created_at,
|
||||
previous=args.previous,
|
||||
notes=args.notes,
|
||||
min_launcher_version=args.min_launcher_version,
|
||||
launcher=launcher,
|
||||
files=files,
|
||||
)
|
||||
|
||||
args.out.write_bytes(canonical_json(manifest))
|
||||
|
||||
total_size = launcher.size + sum(f.size for f in files)
|
||||
print(
|
||||
f"manifest: {args.out} "
|
||||
f"files: {len(files) + 1} "
|
||||
f"total: {total_size / (1024 * 1024):.1f} MiB",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
97
scripts/setup-wine-prefix.sh
Executable file
97
scripts/setup-wine-prefix.sh
Executable file
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
# Set up a Wine prefix for running the Metin2 client on Linux.
|
||||
# Idempotent: re-running on an existing prefix skips steps that are already done.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/setup-wine-prefix.sh <source-client-dir> <target-dir>
|
||||
#
|
||||
# Example:
|
||||
# ./scripts/setup-wine-prefix.sh /mnt/windows_c/Users/me/metin/client ~/metin-wine
|
||||
#
|
||||
# Result layout:
|
||||
# <target-dir>/client/ — writable copy of the client deploy folder
|
||||
# <target-dir>/prefix/ — Wine prefix with required runtime deps installed
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "usage: $0 <source-client-dir> <target-dir>" >&2
|
||||
echo " source-client-dir: path containing Metin2.exe, assets/, pack/, etc." >&2
|
||||
echo " target-dir: directory to create (holds client/ and prefix/)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
SRC=$1
|
||||
DEST=$2
|
||||
|
||||
if [[ ! -f "$SRC/Metin2.exe" && ! -f "$SRC/Metin2_Debug.exe" ]]; then
|
||||
echo "error: $SRC does not look like a client folder (no Metin2.exe or Metin2_Debug.exe)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for tool in wine winetricks; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
echo "error: $tool not found in PATH. Install it via your package manager." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
CLIENT_DIR=$DEST/client
|
||||
PREFIX_DIR=$DEST/prefix
|
||||
|
||||
mkdir -p "$DEST"
|
||||
|
||||
if [[ -d "$CLIENT_DIR" && -f "$CLIENT_DIR/Metin2.exe" ]] || [[ -d "$CLIENT_DIR" && -f "$CLIENT_DIR/Metin2_Debug.exe" ]]; then
|
||||
echo "[1/3] client already present at $CLIENT_DIR, skipping copy"
|
||||
else
|
||||
echo "[1/3] copying client from $SRC to $CLIENT_DIR (this can take a minute)"
|
||||
cp -a "$SRC" "$CLIENT_DIR"
|
||||
fi
|
||||
|
||||
export WINEPREFIX=$PREFIX_DIR
|
||||
export WINEARCH=win64
|
||||
|
||||
if [[ -f "$PREFIX_DIR/system.reg" ]]; then
|
||||
echo "[2/3] wine prefix already exists at $PREFIX_DIR, skipping wineboot"
|
||||
else
|
||||
echo "[2/3] creating wine prefix at $PREFIX_DIR"
|
||||
mkdir -p "$PREFIX_DIR"
|
||||
wineboot --init >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# vcrun2022 — MSVC 2015-2022 runtime, required because the client is an MSVC build
|
||||
# d3dx9 — D3DX9 helper DLLs (Wine implements d3d9 but not the d3dx9 helpers)
|
||||
# corefonts — Arial/Courier/Times/etc., needed by some UI elements
|
||||
# tahoma — the client hard-codes Tahoma as the UI font; without it, all text renders invisibly
|
||||
VERBS=(vcrun2022 d3dx9 corefonts tahoma)
|
||||
TO_INSTALL=()
|
||||
for v in "${VERBS[@]}"; do
|
||||
case $v in
|
||||
vcrun2022)
|
||||
if [[ -f "$PREFIX_DIR/drive_c/windows/system32/msvcp140.dll" ]]; then continue; fi ;;
|
||||
d3dx9)
|
||||
if [[ -f "$PREFIX_DIR/drive_c/windows/system32/d3dx9_43.dll" ]]; then continue; fi ;;
|
||||
corefonts)
|
||||
if [[ -f "$PREFIX_DIR/drive_c/windows/Fonts/arial.ttf" ]]; then continue; fi ;;
|
||||
tahoma)
|
||||
if [[ -f "$PREFIX_DIR/drive_c/windows/Fonts/tahoma.ttf" ]]; then continue; fi ;;
|
||||
esac
|
||||
TO_INSTALL+=("$v")
|
||||
done
|
||||
|
||||
if [[ ${#TO_INSTALL[@]} -eq 0 ]]; then
|
||||
echo "[3/3] all winetricks verbs already installed"
|
||||
else
|
||||
echo "[3/3] installing winetricks verbs: ${TO_INSTALL[*]}"
|
||||
winetricks -q "${TO_INSTALL[@]}"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "done. to launch:"
|
||||
echo
|
||||
echo " cd $CLIENT_DIR"
|
||||
echo " WINEPREFIX=$PREFIX_DIR wine Metin2.exe"
|
||||
echo
|
||||
echo "or with verbose client logging:"
|
||||
echo
|
||||
echo " WINEPREFIX=$PREFIX_DIR WINEDEBUG=-all,+err,+seh wine Metin2_Debug.exe"
|
||||
102
scripts/sign-manifest.py
Executable file
102
scripts/sign-manifest.py
Executable file
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sign a manifest.json with the launcher's Ed25519 private key.
|
||||
|
||||
Produces a detached signature at the same path with a ``.sig`` suffix. The
|
||||
signature is over the **literal bytes** of the manifest file — do not
|
||||
re-serialize the JSON before signing, or the launcher will refuse the result.
|
||||
|
||||
Usage:
|
||||
sign-manifest.py --manifest /path/to/manifest.json \\
|
||||
--key ~/.config/metin/launcher-signing-key \\
|
||||
[--out manifest.json.sig]
|
||||
|
||||
The private key file is 32 raw bytes (no PEM header, no encryption). Create it
|
||||
with:
|
||||
|
||||
python3 -c 'from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey; from cryptography.hazmat.primitives import serialization; p=Ed25519PrivateKey.generate(); open("key","wb").write(p.private_bytes(serialization.Encoding.Raw,serialization.PrivateFormat.Raw,serialization.NoEncryption())); print(p.public_key().public_bytes(serialization.Encoding.Raw,serialization.PublicFormat.Raw).hex())'
|
||||
|
||||
Keep the file ``chmod 600`` on the release machine. Never commit it to any repo
|
||||
and never store it in CI secrets unless the release flow is moved off the CI
|
||||
runner entirely.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
except ImportError:
|
||||
print("error: this script requires the 'cryptography' package "
|
||||
"(pip install cryptography, or dnf install python3-cryptography)",
|
||||
file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def load_private_key(key_path: Path) -> Ed25519PrivateKey:
|
||||
raw = key_path.read_bytes()
|
||||
if len(raw) != 32:
|
||||
raise SystemExit(
|
||||
f"error: private key at {key_path} is {len(raw)} bytes, expected 32 "
|
||||
f"(a raw Ed25519 seed, not a PEM file)"
|
||||
)
|
||||
return Ed25519PrivateKey.from_private_bytes(raw)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--manifest", required=True, type=Path,
|
||||
help="Path to manifest.json to sign")
|
||||
parser.add_argument("--key", required=True, type=Path,
|
||||
help="Path to the raw 32-byte Ed25519 private key")
|
||||
parser.add_argument("--out", type=Path,
|
||||
help="Signature output path (default: <manifest>.sig)")
|
||||
args = parser.parse_args()
|
||||
|
||||
manifest_path: Path = args.manifest
|
||||
if not manifest_path.is_file():
|
||||
print(f"error: manifest not found: {manifest_path}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
key_path: Path = args.key
|
||||
if not key_path.is_file():
|
||||
print(f"error: private key not found: {key_path}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
key_mode = key_path.stat().st_mode & 0o777
|
||||
if key_mode & 0o077:
|
||||
print(
|
||||
f"warning: private key {key_path} is readable by group or world "
|
||||
f"(mode {oct(key_mode)}). chmod 600 recommended.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
private_key = load_private_key(key_path)
|
||||
|
||||
manifest_bytes = manifest_path.read_bytes()
|
||||
signature = private_key.sign(manifest_bytes)
|
||||
assert len(signature) == 64, "Ed25519 signatures are always 64 bytes"
|
||||
|
||||
out_path: Path = args.out or manifest_path.with_suffix(manifest_path.suffix + ".sig")
|
||||
out_path.write_bytes(signature)
|
||||
os.chmod(out_path, 0o644)
|
||||
|
||||
public_hex = private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
).hex()
|
||||
|
||||
print(
|
||||
f"signed {manifest_path} -> {out_path} "
|
||||
f"(64 bytes, verify with public key {public_hex})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user