Compare commits

...

10 Commits

Author SHA1 Message Date
Jan Racek
4aa4e49424 psst 2026-03-24 13:00:54 +01:00
53e993f58e feature: Added psxsplash installer, added basic BSP implementation (non-functional) 2025-09-04 18:01:23 +02:00
0d1e363dbb Fixed texture deduping 2025-08-22 22:19:21 +02:00
ac0e4d8420 Added IsActive flag and scene lua files 2025-04-17 15:36:34 +02:00
9af5d7dd1a I hate lfs 2025-04-15 21:19:40 +02:00
dc9bfcb155 Fixed toolchain install on windows 2025-04-15 14:18:58 +02:00
849e221b32 Fixing lfs, hopefully 2025-04-15 13:32:24 +02:00
2013e31b04 Added automatic toolchain, gcc and make installation 2025-04-15 13:17:30 +02:00
Bandwidth
4cebe93c34 Merge pull request #13 from aliakseikalosha/lua
Update lua asset hadling
2025-04-12 21:13:31 +02:00
aliaksei.kalosha
551eb4c0de remove code repetition
2
2025-04-12 19:21:52 +02:00
136 changed files with 12206 additions and 996 deletions

1
.gitattributes vendored
View File

@@ -26,7 +26,6 @@
*.hdr filter=lfs diff=lfs merge=lfs -text *.hdr filter=lfs diff=lfs merge=lfs -text
*.jpg filter=lfs diff=lfs merge=lfs -text *.jpg filter=lfs diff=lfs merge=lfs -text
*.jpeg filter=lfs diff=lfs merge=lfs -text *.jpeg filter=lfs diff=lfs merge=lfs -text
*.png filter=lfs diff=lfs merge=lfs -text
*.apng filter=lfs diff=lfs merge=lfs -text *.apng filter=lfs diff=lfs merge=lfs -text
*.atsc filter=lfs diff=lfs merge=lfs -text *.atsc filter=lfs diff=lfs merge=lfs -text
*.gif filter=lfs diff=lfs merge=lfs -text *.gif filter=lfs diff=lfs merge=lfs -text

872
Debug.unity Normal file
View File

@@ -0,0 +1,872 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!29 &1
OcclusionCullingSettings:
m_ObjectHideFlags: 0
serializedVersion: 2
m_OcclusionBakeSettings:
smallestOccluder: 5
smallestHole: 0.25
backfaceThreshold: 100
m_SceneGUID: 00000000000000000000000000000000
m_OcclusionCullingData: {fileID: 0}
--- !u!104 &2
RenderSettings:
m_ObjectHideFlags: 0
serializedVersion: 10
m_Fog: 0
m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1}
m_FogMode: 3
m_FogDensity: 0.01
m_LinearFogStart: 0
m_LinearFogEnd: 300
m_AmbientSkyColor: {r: 0.212, g: 0.227, b: 0.259, a: 1}
m_AmbientEquatorColor: {r: 0.114, g: 0.125, b: 0.133, a: 1}
m_AmbientGroundColor: {r: 0.047, g: 0.043, b: 0.035, a: 1}
m_AmbientIntensity: 1
m_AmbientMode: 0
m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1}
m_SkyboxMaterial: {fileID: 10304, guid: 0000000000000000f000000000000000, type: 0}
m_HaloStrength: 0.5
m_FlareStrength: 1
m_FlareFadeSpeed: 3
m_HaloTexture: {fileID: 0}
m_SpotCookie: {fileID: 10001, guid: 0000000000000000e000000000000000, type: 0}
m_DefaultReflectionMode: 0
m_DefaultReflectionResolution: 128
m_ReflectionBounces: 1
m_ReflectionIntensity: 1
m_CustomReflection: {fileID: 0}
m_Sun: {fileID: 0}
m_UseRadianceAmbientProbe: 0
--- !u!157 &3
LightmapSettings:
m_ObjectHideFlags: 0
serializedVersion: 13
m_BakeOnSceneLoad: 0
m_GISettings:
serializedVersion: 2
m_BounceScale: 1
m_IndirectOutputScale: 1
m_AlbedoBoost: 1
m_EnvironmentLightingMode: 0
m_EnableBakedLightmaps: 1
m_EnableRealtimeLightmaps: 0
m_LightmapEditorSettings:
serializedVersion: 12
m_Resolution: 2
m_BakeResolution: 40
m_AtlasSize: 1024
m_AO: 0
m_AOMaxDistance: 1
m_CompAOExponent: 1
m_CompAOExponentDirect: 0
m_ExtractAmbientOcclusion: 0
m_Padding: 2
m_LightmapParameters: {fileID: 0}
m_LightmapsBakeMode: 1
m_TextureCompression: 1
m_ReflectionCompression: 2
m_MixedBakeMode: 2
m_BakeBackend: 1
m_PVRSampling: 1
m_PVRDirectSampleCount: 32
m_PVRSampleCount: 512
m_PVRBounces: 2
m_PVREnvironmentSampleCount: 256
m_PVREnvironmentReferencePointCount: 2048
m_PVRFilteringMode: 1
m_PVRDenoiserTypeDirect: 1
m_PVRDenoiserTypeIndirect: 1
m_PVRDenoiserTypeAO: 1
m_PVRFilterTypeDirect: 0
m_PVRFilterTypeIndirect: 0
m_PVRFilterTypeAO: 0
m_PVREnvironmentMIS: 1
m_PVRCulling: 1
m_PVRFilteringGaussRadiusDirect: 1
m_PVRFilteringGaussRadiusIndirect: 1
m_PVRFilteringGaussRadiusAO: 1
m_PVRFilteringAtrousPositionSigmaDirect: 0.5
m_PVRFilteringAtrousPositionSigmaIndirect: 2
m_PVRFilteringAtrousPositionSigmaAO: 1
m_ExportTrainingData: 0
m_TrainingDataDestination: TrainingData
m_LightProbeSampleCountMultiplier: 4
m_LightingDataAsset: {fileID: 20201, guid: 0000000000000000f000000000000000, type: 0}
m_LightingSettings: {fileID: 0}
--- !u!196 &4
NavMeshSettings:
serializedVersion: 2
m_ObjectHideFlags: 0
m_BuildSettings:
serializedVersion: 3
agentTypeID: 0
agentRadius: 0.5
agentHeight: 2
agentSlope: 45
agentClimb: 0.4
ledgeDropHeight: 0
maxJumpAcrossDistance: 0
minRegionArea: 2
manualCellSize: 0
cellSize: 0.16666667
manualTileSize: 0
tileSize: 256
buildHeightMesh: 0
maxJobWorkers: 0
preserveTilesOutsideBounds: 0
debug:
m_Flags: 0
m_NavMeshData: {fileID: 0}
--- !u!1 &283344192
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 283344197}
- component: {fileID: 283344196}
- component: {fileID: 283344195}
- component: {fileID: 283344194}
- component: {fileID: 283344193}
m_Layer: 0
m_Name: f (1)
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &283344193
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 283344192}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: bea0f31a495202580ac77bd9fd6e99f2, type: 3}
m_Name:
m_EditorClassIdentifier:
IsActive: 1
bitDepth: 8
luaFile: {fileID: 0}
previewNormals: 0
normalPreviewLength: 0.5
--- !u!65 &283344194
BoxCollider:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 283344192}
m_Material: {fileID: 0}
m_IncludeLayers:
serializedVersion: 2
m_Bits: 0
m_ExcludeLayers:
serializedVersion: 2
m_Bits: 0
m_LayerOverridePriority: 0
m_IsTrigger: 0
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 3
m_Size: {x: 1, y: 1, z: 1}
m_Center: {x: 0, y: 0, z: 0}
--- !u!23 &283344195
MeshRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 283344192}
m_Enabled: 1
m_CastShadows: 1
m_ReceiveShadows: 1
m_DynamicOccludee: 1
m_StaticShadowCaster: 0
m_MotionVectors: 1
m_LightProbeUsage: 1
m_ReflectionProbeUsage: 1
m_RayTracingMode: 2
m_RayTraceProcedural: 0
m_RayTracingAccelStructBuildFlagsOverride: 0
m_RayTracingAccelStructBuildFlags: 1
m_SmallMeshCulling: 1
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
- {fileID: 2100000, guid: 31321ba15b8f8eb4c954353edc038b1d, type: 2}
m_StaticBatchInfo:
firstSubMesh: 0
subMeshCount: 0
m_StaticBatchRoot: {fileID: 0}
m_ProbeAnchor: {fileID: 0}
m_LightProbeVolumeOverride: {fileID: 0}
m_ScaleInLightmap: 1
m_ReceiveGI: 1
m_PreserveUVs: 0
m_IgnoreNormalsForChartDetection: 0
m_ImportantGI: 0
m_StitchLightmapSeams: 1
m_SelectedEditorRenderState: 3
m_MinimumChartSize: 4
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_AdditionalVertexStreams: {fileID: 0}
--- !u!33 &283344196
MeshFilter:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 283344192}
m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0}
--- !u!4 &283344197
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 283344192}
serializedVersion: 2
m_LocalRotation: {x: -0.1479161, y: 0.37224695, z: -0.33835596, w: 0.85150945}
m_LocalPosition: {x: -30.19757, y: 0, z: 8.341}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 47.226, z: -43.342}
--- !u!1 &1293128684
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1293128689}
- component: {fileID: 1293128688}
- component: {fileID: 1293128687}
- component: {fileID: 1293128686}
- component: {fileID: 1293128685}
m_Layer: 0
m_Name: f
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &1293128685
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1293128684}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: bea0f31a495202580ac77bd9fd6e99f2, type: 3}
m_Name:
m_EditorClassIdentifier:
IsActive: 1
bitDepth: 8
luaFile: {fileID: 0}
previewNormals: 0
normalPreviewLength: 0.5
--- !u!65 &1293128686
BoxCollider:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1293128684}
m_Material: {fileID: 0}
m_IncludeLayers:
serializedVersion: 2
m_Bits: 0
m_ExcludeLayers:
serializedVersion: 2
m_Bits: 0
m_LayerOverridePriority: 0
m_IsTrigger: 0
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 3
m_Size: {x: 1, y: 1, z: 1}
m_Center: {x: 0, y: 0, z: 0}
--- !u!23 &1293128687
MeshRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1293128684}
m_Enabled: 1
m_CastShadows: 1
m_ReceiveShadows: 1
m_DynamicOccludee: 1
m_StaticShadowCaster: 0
m_MotionVectors: 1
m_LightProbeUsage: 1
m_ReflectionProbeUsage: 1
m_RayTracingMode: 2
m_RayTraceProcedural: 0
m_RayTracingAccelStructBuildFlagsOverride: 0
m_RayTracingAccelStructBuildFlags: 1
m_SmallMeshCulling: 1
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
- {fileID: 2100000, guid: 31321ba15b8f8eb4c954353edc038b1d, type: 2}
m_StaticBatchInfo:
firstSubMesh: 0
subMeshCount: 0
m_StaticBatchRoot: {fileID: 0}
m_ProbeAnchor: {fileID: 0}
m_LightProbeVolumeOverride: {fileID: 0}
m_ScaleInLightmap: 1
m_ReceiveGI: 1
m_PreserveUVs: 0
m_IgnoreNormalsForChartDetection: 0
m_ImportantGI: 0
m_StitchLightmapSeams: 1
m_SelectedEditorRenderState: 3
m_MinimumChartSize: 4
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_AdditionalVertexStreams: {fileID: 0}
--- !u!33 &1293128688
MeshFilter:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1293128684}
m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0}
--- !u!4 &1293128689
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1293128684}
serializedVersion: 2
m_LocalRotation: {x: -0.13355339, y: 0.35044792, z: -0.3301185, w: 0.8662399}
m_LocalPosition: {x: -30.19757, y: 0, z: 10.84006}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 44.053, z: -41.723}
--- !u!1 &1331845601
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1331845603}
- component: {fileID: 1331845602}
m_Layer: 0
m_Name: Exp
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &1331845602
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1331845601}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: ab5195ad94fd173cfb6d48ee06eaf245, type: 3}
m_Name:
m_EditorClassIdentifier:
GTEScaling: 100
SceneLuaFile: {fileID: 0}
BSPPreviewDepth: 2
--- !u!4 &1331845603
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1331845601}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: -31.5414, y: 0, z: 10.89769}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1337453585
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1337453587}
- component: {fileID: 1337453586}
m_Layer: 0
m_Name: Directional Light
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!108 &1337453586
Light:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1337453585}
m_Enabled: 1
serializedVersion: 11
m_Type: 1
m_Color: {r: 1, g: 0.95686275, b: 0.8392157, a: 1}
m_Intensity: 1
m_Range: 10
m_SpotAngle: 30
m_InnerSpotAngle: 21.80208
m_CookieSize: 10
m_Shadows:
m_Type: 2
m_Resolution: -1
m_CustomResolution: -1
m_Strength: 1
m_Bias: 0.05
m_NormalBias: 0.4
m_NearPlane: 0.2
m_CullingMatrixOverride:
e00: 1
e01: 0
e02: 0
e03: 0
e10: 0
e11: 1
e12: 0
e13: 0
e20: 0
e21: 0
e22: 1
e23: 0
e30: 0
e31: 0
e32: 0
e33: 1
m_UseCullingMatrixOverride: 0
m_Cookie: {fileID: 0}
m_DrawHalo: 0
m_Flare: {fileID: 0}
m_RenderMode: 0
m_CullingMask:
serializedVersion: 2
m_Bits: 4294967295
m_RenderingLayerMask: 1
m_Lightmapping: 4
m_LightShadowCasterMode: 0
m_AreaSize: {x: 1, y: 1}
m_BounceIntensity: 1
m_ColorTemperature: 6570
m_UseColorTemperature: 0
m_BoundingSphereOverride: {x: 0, y: 0, z: 0, w: 0}
m_UseBoundingSphereOverride: 0
m_UseViewFrustumForShadowCasterCull: 1
m_ForceVisible: 0
m_ShadowRadius: 0
m_ShadowAngle: 0
m_LightUnit: 1
m_LuxAtDistance: 1
m_EnableSpotReflector: 1
--- !u!4 &1337453587
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1337453585}
serializedVersion: 2
m_LocalRotation: {x: 0.40821788, y: -0.23456968, z: 0.10938163, w: 0.8754261}
m_LocalPosition: {x: 0, y: 3, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 50, y: -30, z: 0}
--- !u!1 &1388445967
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1388445970}
- component: {fileID: 1388445969}
- component: {fileID: 1388445968}
m_Layer: 0
m_Name: Main Camera
m_TagString: MainCamera
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!81 &1388445968
AudioListener:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1388445967}
m_Enabled: 1
--- !u!20 &1388445969
Camera:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1388445967}
m_Enabled: 1
serializedVersion: 2
m_ClearFlags: 1
m_BackGroundColor: {r: 0.19215687, g: 0.3019608, b: 0.4745098, a: 0}
m_projectionMatrixMode: 1
m_GateFitMode: 2
m_FOVAxisMode: 0
m_Iso: 200
m_ShutterSpeed: 0.005
m_Aperture: 16
m_FocusDistance: 10
m_FocalLength: 50
m_BladeCount: 5
m_Curvature: {x: 2, y: 11}
m_BarrelClipping: 0.25
m_Anamorphism: 0
m_SensorSize: {x: 36, y: 24}
m_LensShift: {x: 0, y: 0}
m_NormalizedViewPortRect:
serializedVersion: 2
x: 0
y: 0
width: 1
height: 1
near clip plane: 0.3
far clip plane: 1000
field of view: 60
orthographic: 0
orthographic size: 5
m_Depth: -1
m_CullingMask:
serializedVersion: 2
m_Bits: 4294967295
m_RenderingPath: -1
m_TargetTexture: {fileID: 0}
m_TargetDisplay: 0
m_TargetEye: 3
m_HDR: 1
m_AllowMSAA: 1
m_AllowDynamicResolution: 0
m_ForceIntoRT: 0
m_OcclusionCulling: 1
m_StereoConvergence: 10
m_StereoSeparation: 0.022
--- !u!4 &1388445970
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1388445967}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 1, z: -10}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1392840323
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1392840328}
- component: {fileID: 1392840327}
- component: {fileID: 1392840326}
- component: {fileID: 1392840325}
- component: {fileID: 1392840324}
m_Layer: 0
m_Name: f (2)
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &1392840324
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1392840323}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: bea0f31a495202580ac77bd9fd6e99f2, type: 3}
m_Name:
m_EditorClassIdentifier:
IsActive: 1
bitDepth: 8
luaFile: {fileID: 0}
previewNormals: 0
normalPreviewLength: 0.5
--- !u!65 &1392840325
BoxCollider:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1392840323}
m_Material: {fileID: 0}
m_IncludeLayers:
serializedVersion: 2
m_Bits: 0
m_ExcludeLayers:
serializedVersion: 2
m_Bits: 0
m_LayerOverridePriority: 0
m_IsTrigger: 0
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 3
m_Size: {x: 1, y: 1, z: 1}
m_Center: {x: 0, y: 0, z: 0}
--- !u!23 &1392840326
MeshRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1392840323}
m_Enabled: 1
m_CastShadows: 1
m_ReceiveShadows: 1
m_DynamicOccludee: 1
m_StaticShadowCaster: 0
m_MotionVectors: 1
m_LightProbeUsage: 1
m_ReflectionProbeUsage: 1
m_RayTracingMode: 2
m_RayTraceProcedural: 0
m_RayTracingAccelStructBuildFlagsOverride: 0
m_RayTracingAccelStructBuildFlags: 1
m_SmallMeshCulling: 1
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
- {fileID: 2100000, guid: 31321ba15b8f8eb4c954353edc038b1d, type: 2}
m_StaticBatchInfo:
firstSubMesh: 0
subMeshCount: 0
m_StaticBatchRoot: {fileID: 0}
m_ProbeAnchor: {fileID: 0}
m_LightProbeVolumeOverride: {fileID: 0}
m_ScaleInLightmap: 1
m_ReceiveGI: 1
m_PreserveUVs: 0
m_IgnoreNormalsForChartDetection: 0
m_ImportantGI: 0
m_StitchLightmapSeams: 1
m_SelectedEditorRenderState: 3
m_MinimumChartSize: 4
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_AdditionalVertexStreams: {fileID: 0}
--- !u!33 &1392840327
MeshFilter:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1392840323}
m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0}
--- !u!4 &1392840328
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1392840323}
serializedVersion: 2
m_LocalRotation: {x: -0.409346, y: -0.7736037, z: 0.22623056, w: 0.42754278}
m_LocalPosition: {x: -30.19757, y: 2.041, z: 10.84006}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: -122.144, z: 55.77}
--- !u!1 &1513869424
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1513869429}
- component: {fileID: 1513869428}
- component: {fileID: 1513869427}
- component: {fileID: 1513869426}
- component: {fileID: 1513869425}
m_Layer: 0
m_Name: f (3)
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &1513869425
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1513869424}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: bea0f31a495202580ac77bd9fd6e99f2, type: 3}
m_Name:
m_EditorClassIdentifier:
IsActive: 1
bitDepth: 8
luaFile: {fileID: 0}
previewNormals: 0
normalPreviewLength: 0.5
--- !u!65 &1513869426
BoxCollider:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1513869424}
m_Material: {fileID: 0}
m_IncludeLayers:
serializedVersion: 2
m_Bits: 0
m_ExcludeLayers:
serializedVersion: 2
m_Bits: 0
m_LayerOverridePriority: 0
m_IsTrigger: 0
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 3
m_Size: {x: 1, y: 1, z: 1}
m_Center: {x: 0, y: 0, z: 0}
--- !u!23 &1513869427
MeshRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1513869424}
m_Enabled: 1
m_CastShadows: 1
m_ReceiveShadows: 1
m_DynamicOccludee: 1
m_StaticShadowCaster: 0
m_MotionVectors: 1
m_LightProbeUsage: 1
m_ReflectionProbeUsage: 1
m_RayTracingMode: 2
m_RayTraceProcedural: 0
m_RayTracingAccelStructBuildFlagsOverride: 0
m_RayTracingAccelStructBuildFlags: 1
m_SmallMeshCulling: 1
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
- {fileID: 2100000, guid: 31321ba15b8f8eb4c954353edc038b1d, type: 2}
m_StaticBatchInfo:
firstSubMesh: 0
subMeshCount: 0
m_StaticBatchRoot: {fileID: 0}
m_ProbeAnchor: {fileID: 0}
m_LightProbeVolumeOverride: {fileID: 0}
m_ScaleInLightmap: 1
m_ReceiveGI: 1
m_PreserveUVs: 0
m_IgnoreNormalsForChartDetection: 0
m_ImportantGI: 0
m_StitchLightmapSeams: 1
m_SelectedEditorRenderState: 3
m_MinimumChartSize: 4
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_AdditionalVertexStreams: {fileID: 0}
--- !u!33 &1513869428
MeshFilter:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1513869424}
m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0}
--- !u!4 &1513869429
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1513869424}
serializedVersion: 2
m_LocalRotation: {x: -0.09615398, y: 0.2551484, z: 0.3392829, w: 0.9003004}
m_LocalPosition: {x: -27.156, y: 0, z: 9.065}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: -20.259, y: 24.845, z: 36.791}
--- !u!1660057539 &9223372036854775807
SceneRoots:
m_ObjectHideFlags: 0
m_Roots:
- {fileID: 1388445970}
- {fileID: 1337453587}
- {fileID: 1293128689}
- {fileID: 283344197}
- {fileID: 1392840328}
- {fileID: 1513869429}
- {fileID: 1331845603}

7
Debug.unity.meta Normal file
View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: ab8d113c2664ebf4d899b0826cd5f10c
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: d64fb2a2412d7958ca13d15956c4182b guid: f7b9a2e33a4c4754997cf0dd0f20acc8
folderAsset: yes folderAsset: yes
DefaultImporter: DefaultImporter:
externalObjects: {} externalObjects: {}

8
Editor/Core.meta Normal file
View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8e74ebc4b575d27499f7abd4d82b8849
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,263 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using UnityEditor;
using UnityEngine;
namespace SplashEdit.EditorCode
{
/// <summary>
/// Downloads and installs PCSX-Redux from the official distrib.app CDN.
/// Mirrors the logic from pcsx-redux.js (the official download script).
///
/// Flow: fetch platform manifest → find latest build ID → fetch build manifest →
/// get download URL → download zip → extract to .tools/pcsx-redux/
/// </summary>
public static class PCSXReduxDownloader
{
private const string MANIFEST_BASE = "https://distrib.app/storage/manifests/pcsx-redux/";
private static readonly HttpClient _http;
static PCSXReduxDownloader()
{
var handler = new HttpClientHandler
{
AutomaticDecompression = System.Net.DecompressionMethods.GZip
| System.Net.DecompressionMethods.Deflate
};
_http = new HttpClient(handler);
_http.Timeout = TimeSpan.FromSeconds(60);
_http.DefaultRequestHeaders.UserAgent.ParseAdd("SplashEdit/1.0");
}
/// <summary>
/// Returns the platform variant string for the current platform.
/// </summary>
private static string GetPlatformVariant()
{
switch (Application.platform)
{
case RuntimePlatform.WindowsEditor:
return "dev-win-cli-x64";
case RuntimePlatform.LinuxEditor:
return "dev-linux-x64";
default:
return "dev-win-cli-x64";
}
}
/// <summary>
/// Downloads and installs PCSX-Redux to .tools/pcsx-redux/.
/// Shows progress bar during download.
/// </summary>
public static async Task<bool> DownloadAndInstall(Action<string> log = null)
{
string variant = GetPlatformVariant();
log?.Invoke($"Platform variant: {variant}");
try
{
// Step 1: Fetch the master manifest to get the latest build ID
string manifestUrl = $"{MANIFEST_BASE}{variant}/manifest.json";
log?.Invoke($"Fetching manifest: {manifestUrl}");
string manifestJson = await _http.GetStringAsync(manifestUrl);
// Parse the latest build ID from the manifest.
// The manifest is JSON with a "builds" array. We want the highest ID.
// Simple JSON parsing without dependencies:
int latestBuildId = ParseLatestBuildId(manifestJson);
if (latestBuildId < 0)
{
log?.Invoke("Failed to parse build ID from manifest.");
return false;
}
log?.Invoke($"Latest build ID: {latestBuildId}");
// Step 2: Fetch the specific build manifest
string buildManifestUrl = $"{MANIFEST_BASE}{variant}/manifest-{latestBuildId}.json";
log?.Invoke($"Fetching build manifest...");
string buildManifestJson = await _http.GetStringAsync(buildManifestUrl);
// Parse the download path
string downloadPath = ParseDownloadPath(buildManifestJson);
if (string.IsNullOrEmpty(downloadPath))
{
log?.Invoke("Failed to parse download path from build manifest.");
return false;
}
string downloadUrl = $"https://distrib.app{downloadPath}";
log?.Invoke($"Downloading: {downloadUrl}");
// Step 3: Download the file
string tempFile = Path.Combine(Path.GetTempPath(), $"pcsx-redux-{latestBuildId}.zip");
EditorUtility.DisplayProgressBar("Downloading PCSX-Redux", "Downloading...", 0.1f);
using (var response = await _http.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead))
{
response.EnsureSuccessStatusCode();
long? totalBytes = response.Content.Headers.ContentLength;
long downloadedBytes = 0;
using (var fileStream = File.Create(tempFile))
using (var downloadStream = await response.Content.ReadAsStreamAsync())
{
byte[] buffer = new byte[81920];
int bytesRead;
while ((bytesRead = await downloadStream.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
await fileStream.WriteAsync(buffer, 0, bytesRead);
downloadedBytes += bytesRead;
if (totalBytes.HasValue)
{
float progress = (float)downloadedBytes / totalBytes.Value;
string sizeMB = $"{downloadedBytes / (1024 * 1024)}/{totalBytes.Value / (1024 * 1024)} MB";
EditorUtility.DisplayProgressBar("Downloading PCSX-Redux",
$"Downloading... {sizeMB}", progress);
}
}
}
}
log?.Invoke($"Downloaded to {tempFile}");
EditorUtility.DisplayProgressBar("Installing PCSX-Redux", "Extracting...", 0.9f);
// Step 4: Extract
string installDir = SplashBuildPaths.PCSXReduxDir;
if (Directory.Exists(installDir))
Directory.Delete(installDir, true);
Directory.CreateDirectory(installDir);
if (Application.platform == RuntimePlatform.LinuxEditor && tempFile.EndsWith(".tar.gz"))
{
var psi = new ProcessStartInfo
{
FileName = "tar",
Arguments = $"xzf \"{tempFile}\" -C \"{installDir}\" --strip-components=1",
UseShellExecute = false,
CreateNoWindow = true
};
var proc = Process.Start(psi);
proc?.WaitForExit();
}
else
{
System.IO.Compression.ZipFile.ExtractToDirectory(tempFile, installDir);
log?.Invoke($"Extracted to {installDir}");
}
// Clean up temp file
try { File.Delete(tempFile); } catch { }
// Step 5: Verify
if (SplashBuildPaths.IsPCSXReduxInstalled())
{
log?.Invoke("PCSX-Redux installed successfully!");
EditorUtility.ClearProgressBar();
return true;
}
else
{
// The zip might have a nested directory — try to find the exe
FixNestedDirectory(installDir);
if (SplashBuildPaths.IsPCSXReduxInstalled())
{
log?.Invoke("PCSX-Redux installed successfully!");
EditorUtility.ClearProgressBar();
return true;
}
log?.Invoke("Installation completed but PCSX-Redux binary not found at expected path.");
log?.Invoke($"Expected: {SplashBuildPaths.PCSXReduxBinary}");
log?.Invoke($"Check: {installDir}");
EditorUtility.ClearProgressBar();
return false;
}
}
catch (Exception ex)
{
log?.Invoke($"Download failed: {ex.Message}");
EditorUtility.ClearProgressBar();
return false;
}
}
/// <summary>
/// If the zip extracts into a nested directory, move files up.
/// </summary>
private static void FixNestedDirectory(string installDir)
{
var subdirs = Directory.GetDirectories(installDir);
if (subdirs.Length == 1)
{
string nested = subdirs[0];
foreach (string file in Directory.GetFiles(nested))
{
string dest = Path.Combine(installDir, Path.GetFileName(file));
File.Move(file, dest);
}
foreach (string dir in Directory.GetDirectories(nested))
{
string dest = Path.Combine(installDir, Path.GetFileName(dir));
Directory.Move(dir, dest);
}
try { Directory.Delete(nested); } catch { }
}
}
/// <summary>
/// Parse the latest build ID from the master manifest JSON.
/// Expected format: {"builds":[{"id":1234,...},...],...}
/// distrib.app returns builds sorted newest-first, so we take the first.
/// Falls back to scanning all IDs if the "builds" section isn't found.
/// </summary>
private static int ParseLatestBuildId(string json)
{
// Fast path: find the first "id" inside "builds" array
int buildsIdx = json.IndexOf("\"builds\"", StringComparison.Ordinal);
int startPos = buildsIdx >= 0 ? buildsIdx : 0;
string searchToken = "\"id\":";
int idx = json.IndexOf(searchToken, startPos, StringComparison.Ordinal);
if (idx < 0) return -1;
int pos = idx + searchToken.Length;
while (pos < json.Length && char.IsWhiteSpace(json[pos])) pos++;
int numStart = pos;
while (pos < json.Length && char.IsDigit(json[pos])) pos++;
if (pos > numStart && int.TryParse(json.Substring(numStart, pos - numStart), out int id))
return id;
return -1;
}
/// <summary>
/// Parse the download path from a build-specific manifest.
/// Expected format: {...,"path":"/storage/builds/..."}
/// </summary>
private static string ParseDownloadPath(string json)
{
string searchToken = "\"path\":";
int idx = json.IndexOf(searchToken, StringComparison.Ordinal);
if (idx < 0) return null;
int pos = idx + searchToken.Length;
while (pos < json.Length && char.IsWhiteSpace(json[pos])) pos++;
if (pos >= json.Length || json[pos] != '"') return null;
pos++; // skip opening quote
int pathStart = pos;
while (pos < json.Length && json[pos] != '"') pos++;
return json.Substring(pathStart, pos - pathStart);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b3eaffbb1caed9648b5b57d211ead4d6

View File

@@ -0,0 +1,769 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Ports;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
namespace SplashEdit.EditorCode
{
/// <summary>
/// PCdrv Host — serves files to a PS1 over serial (Unirom/NOTPSXSerial protocol).
///
/// PCdrv uses MIPS `break` instructions to request file I/O from the host.
/// For real hardware running Unirom, we must:
/// 1. Enter debug mode (DEBG/OKAY) — installs Unirom's kernel-resident SIO handler
/// 2. Continue execution (CONT/OKAY)
/// 3. Monitor serial for escape sequences: 0x00 followed by 'p' = PCDrv command
/// 4. Handle file operations (init, open, read, close, seek, etc.)
///
/// Without entering debug mode first, `break` instructions cause an unhandled
/// "BP break (0x9)" crash because no handler is registered.
///
/// Protocol based on NOTPSXSerial: https://github.com/JonathanDotCel/NOTPSXSerial
/// </summary>
public class PCdrvSerialHost : IDisposable
{
private SerialPort _port;
private CancellationTokenSource _cts;
private Task _listenTask;
private readonly string _portName;
private readonly int _baudRate;
private readonly string _baseDir;
private readonly Action<string> _log;
private readonly Action<string> _psxLog;
// File handle table (1-indexed, handles are not recycled)
private readonly List<PCFile> _files = new List<PCFile>();
private class PCFile
{
public string Name;
public FileStream Stream;
public int Handle;
public bool Closed;
public FileAccess Mode;
}
// Protocol escape char — PCDrv commands are prefixed with 0x00 + 'p'
private const byte ESCAPE_CHAR = 0x00;
// PCDrv function codes (from Unirom kernel)
private const int FUNC_INIT = 0x101;
private const int FUNC_CREAT = 0x102;
private const int FUNC_OPEN = 0x103;
private const int FUNC_CLOSE = 0x104;
private const int FUNC_READ = 0x105;
private const int FUNC_WRITE = 0x106;
private const int FUNC_SEEK = 0x107;
public bool IsRunning => _listenTask != null && !_listenTask.IsCompleted;
public PCdrvSerialHost(string portName, int baudRate, string baseDir, Action<string> log, Action<string> psxLog = null)
{
_portName = portName;
_baudRate = baudRate;
_baseDir = baseDir;
_log = log;
_psxLog = psxLog;
}
/// <summary>
/// Opens a new serial port and begins the monitor/PCdrv loop.
/// Note: DEBG must have been sent BEFORE the exe was uploaded (via UniromUploader.UploadExeForPCdrv).
/// Use the Start(SerialPort) overload to pass an already-open port from the uploader.
/// </summary>
public void Start()
{
if (IsRunning) return;
_port = new SerialPort(_portName, _baudRate)
{
ReadTimeout = 5000,
WriteTimeout = 5000,
StopBits = StopBits.Two,
Parity = Parity.None,
DataBits = 8,
Handshake = Handshake.None,
DtrEnable = true,
RtsEnable = true
};
_port.Open();
_log?.Invoke($"PCdrv host: opened {_portName} @ {_baudRate}");
_log?.Invoke($"PCdrv host: serving files from {_baseDir}");
StartMonitorLoop();
}
/// <summary>
/// Starts the PCDrv monitor loop on an already-open serial port.
/// Use this after UniromUploader.UploadExeForPCdrv() which sends DEBG → SEXE
/// and returns the open port. The debug hooks are already installed and the
/// exe is already running — we just need to listen for escape sequences.
/// </summary>
public void Start(SerialPort openPort)
{
if (IsRunning) return;
_port = openPort;
_log?.Invoke($"PCdrv host: serving files from {_baseDir}");
_log?.Invoke("PCdrv host: monitoring for PCDrv requests...");
StartMonitorLoop();
}
private void StartMonitorLoop()
{
_cts = new CancellationTokenSource();
_listenTask = Task.Run(() => MonitorLoop(_cts.Token));
}
public void Stop()
{
_cts?.Cancel();
try { _listenTask?.Wait(2000); } catch { }
foreach (var f in _files)
{
if (!f.Closed && f.Stream != null)
{
try { f.Stream.Close(); f.Stream.Dispose(); } catch { }
}
}
_files.Clear();
if (_port != null && _port.IsOpen)
{
try { _port.Close(); } catch { }
}
_port?.Dispose();
_port = null;
_log?.Invoke("PCdrv host stopped.");
}
public void Dispose() => Stop();
// ═══════════════════════════════════════════════════════════════
// Monitor loop — reads serial byte-by-byte looking for escape sequences
// Matches NOTPSXSerial's Bridge.MonitorSerial()
// ═══════════════════════════════════════════════════════════════
private void MonitorLoop(CancellationToken ct)
{
bool lastByteWasEscape = false;
var textBuffer = new StringBuilder();
int totalBytesReceived = 0;
DateTime lastLogTime = DateTime.Now;
_log?.Invoke("PCdrv monitor: waiting for data from PS1...");
while (!ct.IsCancellationRequested)
{
try
{
if (_port.BytesToRead == 0)
{
// Flush any accumulated text output periodically
if (textBuffer.Length > 0 && (DateTime.Now - lastLogTime).TotalMilliseconds > 100)
{
EmitPsxLine(textBuffer.ToString());
textBuffer.Clear();
lastLogTime = DateTime.Now;
}
Thread.Sleep(1);
continue;
}
int b = _port.ReadByte();
totalBytesReceived++;
// Log first bytes received to help diagnose protocol issues
if (totalBytesReceived <= 32)
{
_log?.Invoke($"PCdrv monitor: byte #{totalBytesReceived} = 0x{b:X2} ('{(b >= 0x20 && b < 0x7F ? (char)b : '.')}')");
}
else if (totalBytesReceived == 33)
{
_log?.Invoke("PCdrv monitor: (suppressing per-byte logging, check PS1> lines for output)");
}
if (lastByteWasEscape)
{
lastByteWasEscape = false;
// Flush any text before handling escape
if (textBuffer.Length > 0)
{
EmitPsxLine(textBuffer.ToString());
textBuffer.Clear();
}
if (b == ESCAPE_CHAR)
{
// Double escape = literal 0x00 in output, ignore
continue;
}
if (b == 'p')
{
// PCDrv command incoming
_log?.Invoke("PCdrv monitor: got escape+p → PCDrv command!");
HandlePCDrvCommand(ct);
}
else
{
// Unknown escape sequence — log it
_log?.Invoke($"PCdrv monitor: unknown escape seq: 0x00 + 0x{b:X2} ('{(b >= 0x20 && b < 0x7F ? (char)b : '.')}')");
}
continue;
}
if (b == ESCAPE_CHAR)
{
lastByteWasEscape = true;
continue;
}
// Regular byte — this is printf output from the PS1
if (b == '\n' || b == '\r')
{
if (textBuffer.Length > 0)
{
EmitPsxLine(textBuffer.ToString());
textBuffer.Clear();
lastLogTime = DateTime.Now;
}
}
else if (b >= 0x20 && b < 0x7F)
{
textBuffer.Append((char)b);
// Flush long lines immediately
if (textBuffer.Length >= 200)
{
EmitPsxLine(textBuffer.ToString());
textBuffer.Clear();
lastLogTime = DateTime.Now;
}
}
// else: non-printable byte that's not escape, skip
}
catch (TimeoutException) { }
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
if (!ct.IsCancellationRequested)
_log?.Invoke($"PCdrv monitor error: {ex.Message}");
}
}
}
// ═══════════════════════════════════════════════════════════════
// PCDrv command dispatcher
// Matches NOTPSXSerial's PCDrv.ReadCommand()
// ═══════════════════════════════════════════════════════════════
private void HandlePCDrvCommand(CancellationToken ct)
{
int funcCode = ReadInt32(ct);
switch (funcCode)
{
case FUNC_INIT: HandleInit(); break;
case FUNC_CREAT: HandleCreate(ct); break;
case FUNC_OPEN: HandleOpen(ct); break;
case FUNC_CLOSE: HandleClose(ct); break;
case FUNC_READ: HandleRead(ct); break;
case FUNC_WRITE: HandleWrite(ct); break;
case FUNC_SEEK: HandleSeek(ct); break;
default:
_log?.Invoke($"PCdrv: unknown function 0x{funcCode:X}");
break;
}
}
// ═══════════════════════════════════════════════════════════════
// Individual PCDrv handlers — match NOTPSXSerial's PCDrv.cs
// ═══════════════════════════════════════════════════════════════
private void HandleInit()
{
_log?.Invoke("PCdrv: INIT");
SendString("OKAY");
_port.Write(new byte[] { 0 }, 0, 1); // null terminator expected by Unirom
}
private void HandleOpen(CancellationToken ct)
{
// Unirom sends: we respond OKAY first, then read filename + mode
SendString("OKAY");
string filename = ReadNullTermString(ct);
int modeParam = ReadInt32(ct);
// Log raw bytes for debugging garbled filenames
_log?.Invoke($"PCdrv: OPEN \"{filename}\" mode={modeParam} (len={filename.Length}, hex={BitConverter.ToString(System.Text.Encoding.ASCII.GetBytes(filename))})");
// Check if already open
var existing = FindOpenFile(filename);
if (existing != null)
{
_log?.Invoke($"PCdrv: already open, handle={existing.Handle}");
SendString("OKAY");
WriteInt32(existing.Handle);
return;
}
string fullPath;
try
{
fullPath = ResolvePath(filename);
}
catch (Exception ex)
{
_log?.Invoke($"PCdrv: invalid filename \"{filename}\": {ex.Message}");
SendString("NOPE");
return;
}
if (!File.Exists(fullPath))
{
_log?.Invoke($"PCdrv: file not found: {fullPath}");
SendString("NOPE");
return;
}
try
{
var fs = new FileStream(fullPath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite);
int handle = NextHandle();
_files.Add(new PCFile { Name = filename, Stream = fs, Handle = handle, Closed = false, Mode = FileAccess.ReadWrite });
SendString("OKAY");
WriteInt32(handle);
_log?.Invoke($"PCdrv: opened handle={handle}");
}
catch (Exception ex)
{
_log?.Invoke($"PCdrv: open failed: {ex.Message}");
SendString("NOPE");
}
}
private void HandleCreate(CancellationToken ct)
{
SendString("OKAY");
string filename = ReadNullTermString(ct);
int parameters = ReadInt32(ct);
_log?.Invoke($"PCdrv: CREAT \"{filename}\" params={parameters}");
var existing = FindOpenFile(filename);
if (existing != null)
{
SendString("OKAY");
WriteInt32(existing.Handle);
return;
}
string fullPath;
try { fullPath = ResolvePath(filename); }
catch (Exception ex)
{
_log?.Invoke($"PCdrv: invalid filename \"{filename}\": {ex.Message}");
SendString("NOPE");
return;
}
try
{
// Create or truncate the file
if (!File.Exists(fullPath))
{
var temp = File.Create(fullPath);
temp.Flush(); temp.Close(); temp.Dispose();
}
var fs = new FileStream(fullPath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite);
int handle = NextHandle();
_files.Add(new PCFile { Name = filename, Stream = fs, Handle = handle, Closed = false, Mode = FileAccess.ReadWrite });
SendString("OKAY");
WriteInt32(handle);
_log?.Invoke($"PCdrv: created handle={handle}");
}
catch (Exception ex)
{
_log?.Invoke($"PCdrv: create failed: {ex.Message}");
SendString("NOPE");
}
}
private void HandleClose(CancellationToken ct)
{
// Unirom sends: we respond OKAY first, then read handle + 2 unused params
SendString("OKAY");
int handle = ReadInt32(ct);
int _unused1 = ReadInt32(ct);
int _unused2 = ReadInt32(ct);
_log?.Invoke($"PCdrv: CLOSE handle={handle}");
var f = FindOpenFile(handle);
if (f == null)
{
// No such file — "great success" per NOTPSXSerial
SendString("OKAY");
WriteInt32(0);
return;
}
try
{
f.Stream.Close();
f.Stream.Dispose();
f.Closed = true;
SendString("OKAY");
WriteInt32(handle);
_log?.Invoke($"PCdrv: closed handle={handle}");
}
catch (Exception ex)
{
_log?.Invoke($"PCdrv: close error: {ex.Message}");
SendString("NOPE");
}
}
private void HandleRead(CancellationToken ct)
{
// Unirom sends: we respond OKAY first, then read handle + len + memaddr
SendString("OKAY");
int handle = ReadInt32(ct);
int length = ReadInt32(ct);
int memAddr = ReadInt32(ct); // for debugging only
_log?.Invoke($"PCdrv: READ handle={handle} len=0x{length:X} memAddr=0x{memAddr:X}");
var f = FindOpenFile(handle);
if (f == null)
{
_log?.Invoke($"PCdrv: no file with handle {handle}");
SendString("NOPE");
return;
}
try
{
byte[] data = new byte[length];
int bytesRead = f.Stream.Read(data, 0, length);
SendString("OKAY");
WriteInt32(data.Length);
// Checksum (simple byte sum, forced V3 = true per NOTPSXSerial)
uint checksum = CalculateChecksum(data);
WriteUInt32(checksum);
// Send data using chunked writer (with per-chunk ack for V2+)
WriteDataChunked(data);
_log?.Invoke($"PCdrv: sent {bytesRead} bytes for handle={handle}");
}
catch (Exception ex)
{
_log?.Invoke($"PCdrv: read error: {ex.Message}");
SendString("NOPE");
}
}
private void HandleWrite(CancellationToken ct)
{
SendString("OKAY");
int handle = ReadInt32(ct);
int length = ReadInt32(ct);
int memAddr = ReadInt32(ct);
_log?.Invoke($"PCdrv: WRITE handle={handle} len={length}");
var f = FindOpenFile(handle);
if (f == null)
{
SendString("NOPE");
return;
}
SendString("OKAY");
// Read data from PSX
byte[] data = ReadBytes(length, ct);
f.Stream.Write(data, 0, length);
f.Stream.Flush();
SendString("OKAY");
WriteInt32(length);
_log?.Invoke($"PCdrv: wrote {length} bytes to handle={handle}");
}
private void HandleSeek(CancellationToken ct)
{
SendString("OKAY");
int handle = ReadInt32(ct);
int offset = ReadInt32(ct);
int whence = ReadInt32(ct);
_log?.Invoke($"PCdrv: SEEK handle={handle} offset={offset} whence={whence}");
var f = FindOpenFile(handle);
if (f == null)
{
SendString("NOPE");
return;
}
SeekOrigin origin = whence switch
{
0 => SeekOrigin.Begin,
1 => SeekOrigin.Current,
2 => SeekOrigin.End,
_ => SeekOrigin.Begin
};
try
{
long newPos = f.Stream.Seek(offset, origin);
SendString("OKAY");
WriteInt32((int)newPos);
}
catch (Exception ex)
{
_log?.Invoke($"PCdrv: seek error: {ex.Message}");
SendString("NOPE");
}
}
// ═══════════════════════════════════════════════════════════════
// PS1 output routing
// ═══════════════════════════════════════════════════════════════
/// <summary>
/// Routes PS1 printf output to PSXConsoleWindow (via _psxLog) if available,
/// otherwise falls back to the control panel log.
/// </summary>
private void EmitPsxLine(string text)
{
if (_psxLog != null)
_psxLog.Invoke(text);
else
_log?.Invoke($"PS1> {text}");
}
// ═══════════════════════════════════════════════════════════════
// Chunked data write — matches NOTPSXSerial's WriteBytes()
// Sends data in 2048-byte chunks; for protocol V2+ Unirom
// responds with CHEK/MORE/ERR! per chunk.
// ═══════════════════════════════════════════════════════════════
private void WriteDataChunked(byte[] data)
{
int chunkSize = 2048;
for (int i = 0; i < data.Length; i += chunkSize)
{
int thisChunk = Math.Min(chunkSize, data.Length - i);
_port.Write(data, i, thisChunk);
// Wait for bytes to drain
while (_port.BytesToWrite > 0)
Thread.Sleep(0);
// V2 protocol: wait for CHEK, send chunk checksum, wait for MORE
// For now, handle this if present
if (_port.BytesToRead >= 4)
{
string resp = ReadFixedString(4);
if (resp == "CHEK")
{
ulong chunkSum = 0;
for (int j = 0; j < thisChunk; j++)
chunkSum += data[i + j];
_port.Write(BitConverter.GetBytes((uint)chunkSum), 0, 4);
Thread.Sleep(1);
// Wait for MORE or ERR!
string ack = WaitFor4CharResponse(5000);
if (ack == "ERR!")
{
_log?.Invoke("PCdrv: chunk checksum error, retrying...");
i -= chunkSize; // retry
}
}
}
}
}
// ═══════════════════════════════════════════════════════════════
// File handle helpers
// ═══════════════════════════════════════════════════════════════
private int NextHandle() => _files.Count + 1;
private PCFile FindOpenFile(string name)
{
for (int i = 0; i < _files.Count; i++)
{
if (!_files[i].Closed && _files[i].Name.Equals(name, StringComparison.OrdinalIgnoreCase))
return _files[i];
}
return null;
}
private PCFile FindOpenFile(int handle)
{
for (int i = 0; i < _files.Count; i++)
{
if (!_files[i].Closed && _files[i].Handle == handle)
return _files[i];
}
return null;
}
private string ResolvePath(string filename)
{
// Strip leading slashes and backslashes
filename = filename.TrimStart('/', '\\');
return Path.Combine(_baseDir, filename);
}
// ═══════════════════════════════════════════════════════════════
// Low-level serial I/O
// ═══════════════════════════════════════════════════════════════
private int ReadInt32(CancellationToken ct)
{
byte[] buf = new byte[4];
for (int i = 0; i < 4; i++)
buf[i] = (byte)ReadByteBlocking(ct);
return BitConverter.ToInt32(buf, 0);
}
private uint ReadUInt32(CancellationToken ct)
{
byte[] buf = new byte[4];
for (int i = 0; i < 4; i++)
buf[i] = (byte)ReadByteBlocking(ct);
return BitConverter.ToUInt32(buf, 0);
}
private byte[] ReadBytes(int count, CancellationToken ct)
{
byte[] data = new byte[count];
int pos = 0;
while (pos < count)
{
ct.ThrowIfCancellationRequested();
if (_port.BytesToRead > 0)
{
int read = _port.Read(data, pos, Math.Min(count - pos, _port.BytesToRead));
pos += read;
}
else
{
Thread.Sleep(1);
}
}
return data;
}
private int ReadByteBlocking(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
if (_port.BytesToRead > 0)
return _port.ReadByte();
Thread.Sleep(1);
}
throw new OperationCanceledException();
}
private string ReadNullTermString(CancellationToken ct)
{
var sb = new StringBuilder();
while (true)
{
int b = ReadByteBlocking(ct);
if (b == 0) break;
sb.Append((char)b);
if (sb.Length > 255) break;
}
return sb.ToString();
}
private void SendString(string s)
{
byte[] data = Encoding.ASCII.GetBytes(s);
_port.Write(data, 0, data.Length);
}
private void WriteInt32(int value)
{
_port.Write(BitConverter.GetBytes(value), 0, 4);
}
private void WriteUInt32(uint value)
{
_port.Write(BitConverter.GetBytes(value), 0, 4);
}
private string ReadFixedString(int count)
{
var sb = new StringBuilder();
for (int i = 0; i < count; i++)
{
if (_port.BytesToRead > 0)
sb.Append((char)_port.ReadByte());
}
return sb.ToString();
}
private string WaitFor4CharResponse(int timeoutMs)
{
string buffer = "";
DateTime deadline = DateTime.Now.AddMilliseconds(timeoutMs);
while (DateTime.Now < deadline)
{
if (_port.BytesToRead > 0)
{
buffer += (char)_port.ReadByte();
if (buffer.Length > 4)
buffer = buffer.Substring(buffer.Length - 4);
if (buffer == "MORE" || buffer == "ERR!")
return buffer;
}
else
{
Thread.Sleep(1);
}
}
return buffer;
}
private static uint CalculateChecksum(byte[] data)
{
// Force V3-style for PCDrv reads (per NOTPSXSerial: forceProtocolV3=true)
uint sum = 0;
for (int i = 0; i < data.Length; i++)
sum += data[i];
return sum;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d27c6a94b1c1f07418799b65d13f7097

View File

@@ -0,0 +1,318 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using SplashEdit.RuntimeCode;
using UnityEditor;
using UnityEngine;
using Debug = UnityEngine.Debug;
namespace SplashEdit.EditorCode
{
/// <summary>
/// Downloads psxavenc and converts WAV audio to PS1 SPU ADPCM format.
/// psxavenc is the standard tool for PS1 audio encoding from the
/// WonderfulToolchain project.
/// </summary>
[InitializeOnLoad]
public static class PSXAudioConverter
{
static PSXAudioConverter()
{
// Register the converter delegate so Runtime code can call it
// without directly referencing this Editor assembly.
PSXSceneExporter.AudioConvertDelegate = ConvertToADPCM;
}
private const string PSXAVENC_VERSION = "v0.3.1";
private const string PSXAVENC_RELEASE_BASE =
"https://github.com/WonderfulToolchain/psxavenc/releases/download/";
private static readonly HttpClient _http = new HttpClient();
/// <summary>
/// Path to the psxavenc binary inside .tools/
/// </summary>
public static string PsxavencBinary
{
get
{
string dir = Path.Combine(SplashBuildPaths.ToolsDir, "psxavenc");
if (Application.platform == RuntimePlatform.WindowsEditor)
return Path.Combine(dir, "psxavenc.exe");
return Path.Combine(dir, "psxavenc");
}
}
public static bool IsInstalled() => File.Exists(PsxavencBinary);
/// <summary>
/// Downloads and installs psxavenc from the official GitHub releases.
/// </summary>
public static async Task<bool> DownloadAndInstall(Action<string> log = null)
{
string platformSuffix;
string archiveName;
switch (Application.platform)
{
case RuntimePlatform.WindowsEditor:
platformSuffix = "x86_64-pc-windows-msvc";
archiveName = $"psxavenc-{PSXAVENC_VERSION}-{platformSuffix}.zip";
break;
case RuntimePlatform.LinuxEditor:
platformSuffix = "x86_64-unknown-linux-gnu";
archiveName = $"psxavenc-{PSXAVENC_VERSION}-{platformSuffix}.tar.gz";
break;
default:
log?.Invoke("Only Windows and Linux are supported.");
return false;
}
string downloadUrl = $"{PSXAVENC_RELEASE_BASE}{PSXAVENC_VERSION}/{archiveName}";
log?.Invoke($"Downloading psxavenc: {downloadUrl}");
try
{
string tempFile = Path.Combine(Path.GetTempPath(), archiveName);
EditorUtility.DisplayProgressBar("Downloading psxavenc", "Downloading...", 0.1f);
using (var response = await _http.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead))
{
response.EnsureSuccessStatusCode();
long? totalBytes = response.Content.Headers.ContentLength;
long downloaded = 0;
using (var fs = File.Create(tempFile))
using (var stream = await response.Content.ReadAsStreamAsync())
{
byte[] buffer = new byte[81920];
int bytesRead;
while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
await fs.WriteAsync(buffer, 0, bytesRead);
downloaded += bytesRead;
if (totalBytes.HasValue)
{
float progress = (float)downloaded / totalBytes.Value;
EditorUtility.DisplayProgressBar("Downloading psxavenc",
$"{downloaded / 1024}/{totalBytes.Value / 1024} KB", progress);
}
}
}
}
log?.Invoke("Extracting...");
EditorUtility.DisplayProgressBar("Installing psxavenc", "Extracting...", 0.9f);
string installDir = Path.Combine(SplashBuildPaths.ToolsDir, "psxavenc");
if (Directory.Exists(installDir))
Directory.Delete(installDir, true);
Directory.CreateDirectory(installDir);
if (tempFile.EndsWith(".zip"))
{
System.IO.Compression.ZipFile.ExtractToDirectory(tempFile, installDir);
}
else
{
// tar.gz extraction — use system tar
var psi = new ProcessStartInfo
{
FileName = "tar",
Arguments = $"xzf \"{tempFile}\" -C \"{installDir}\" --strip-components=1",
UseShellExecute = false,
CreateNoWindow = true
};
var proc = Process.Start(psi);
proc.WaitForExit();
}
// Fix nested directory (sometimes archives have one extra level)
FixNestedDirectory(installDir);
try { File.Delete(tempFile); } catch { }
EditorUtility.ClearProgressBar();
if (IsInstalled())
{
// Make executable on Linux
if (Application.platform == RuntimePlatform.LinuxEditor)
{
var chmod = Process.Start("chmod", $"+x \"{PsxavencBinary}\"");
chmod?.WaitForExit();
}
log?.Invoke("psxavenc installed successfully!");
return true;
}
log?.Invoke($"psxavenc binary not found at: {PsxavencBinary}");
return false;
}
catch (Exception ex)
{
log?.Invoke($"psxavenc download failed: {ex.Message}");
EditorUtility.ClearProgressBar();
return false;
}
}
private static void FixNestedDirectory(string dir)
{
// If extraction created exactly one subdirectory, flatten it
var subdirs = Directory.GetDirectories(dir);
if (subdirs.Length == 1)
{
string nested = subdirs[0];
foreach (string file in Directory.GetFiles(nested))
{
string dest = Path.Combine(dir, Path.GetFileName(file));
if (!File.Exists(dest)) File.Move(file, dest);
}
foreach (string sub in Directory.GetDirectories(nested))
{
string dest = Path.Combine(dir, Path.GetFileName(sub));
if (!Directory.Exists(dest)) Directory.Move(sub, dest);
}
try { Directory.Delete(nested, true); } catch { }
}
}
/// <summary>
/// Converts a Unity AudioClip to PS1 SPU ADPCM format using psxavenc.
/// Returns the ADPCM byte array, or null on failure.
/// </summary>
public static byte[] ConvertToADPCM(AudioClip clip, int targetSampleRate, bool loop)
{
if (!IsInstalled())
{
Debug.LogError("[SplashEdit] psxavenc not installed. Install it from the Setup tab.");
return null;
}
if (clip == null)
{
Debug.LogError("[SplashEdit] AudioClip is null.");
return null;
}
// Export Unity AudioClip to a temporary WAV file
string tempWav = Path.Combine(Path.GetTempPath(), $"psx_audio_{clip.name}.wav");
string tempVag = Path.Combine(Path.GetTempPath(), $"psx_audio_{clip.name}.vag");
try
{
ExportWav(clip, tempWav);
// Run psxavenc: convert WAV to SPU ADPCM
// -t spu: raw SPU ADPCM output (no header, ready for DMA upload)
// -f <rate>: target sample rate
// -L: enable looping flag in the last ADPCM block
string loopFlag = loop ? "-L" : "";
string args = $"-t spu -f {targetSampleRate} {loopFlag} \"{tempWav}\" \"{tempVag}\"";
var psi = new ProcessStartInfo
{
FileName = PsxavencBinary,
Arguments = args,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true
};
var process = Process.Start(psi);
string stderr = process.StandardError.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0)
{
Debug.LogError($"[SplashEdit] psxavenc failed: {stderr}");
return null;
}
if (!File.Exists(tempVag))
{
Debug.LogError("[SplashEdit] psxavenc produced no output file.");
return null;
}
// -t spu outputs raw SPU ADPCM blocks (no header) — use directly.
byte[] adpcm = File.ReadAllBytes(tempVag);
if (adpcm.Length == 0)
{
Debug.LogError("[SplashEdit] psxavenc produced empty output.");
return null;
}
return adpcm;
}
finally
{
try { if (File.Exists(tempWav)) File.Delete(tempWav); } catch { }
try { if (File.Exists(tempVag)) File.Delete(tempVag); } catch { }
}
}
/// <summary>
/// Exports a Unity AudioClip to a 16-bit mono WAV file.
/// </summary>
private static void ExportWav(AudioClip clip, string path)
{
float[] samples = new float[clip.samples * clip.channels];
clip.GetData(samples, 0);
// Downmix to mono if stereo
float[] mono;
if (clip.channels > 1)
{
mono = new float[clip.samples];
for (int i = 0; i < clip.samples; i++)
{
float sum = 0;
for (int ch = 0; ch < clip.channels; ch++)
sum += samples[i * clip.channels + ch];
mono[i] = sum / clip.channels;
}
}
else
{
mono = samples;
}
// Write WAV
using (var fs = new FileStream(path, FileMode.Create))
using (var writer = new BinaryWriter(fs))
{
int sampleCount = mono.Length;
int dataSize = sampleCount * 2; // 16-bit
int fileSize = 44 + dataSize;
// RIFF header
writer.Write(new char[] { 'R', 'I', 'F', 'F' });
writer.Write(fileSize - 8);
writer.Write(new char[] { 'W', 'A', 'V', 'E' });
// fmt chunk
writer.Write(new char[] { 'f', 'm', 't', ' ' });
writer.Write(16); // chunk size
writer.Write((short)1); // PCM
writer.Write((short)1); // mono
writer.Write(clip.frequency);
writer.Write(clip.frequency * 2); // byte rate
writer.Write((short)2); // block align
writer.Write((short)16); // bits per sample
// data chunk
writer.Write(new char[] { 'd', 'a', 't', 'a' });
writer.Write(dataSize);
for (int i = 0; i < sampleCount; i++)
{
short sample = (short)(Mathf.Clamp(mono[i], -1f, 1f) * 32767f);
writer.Write(sample);
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 372b2ef07e125584ba43312b0662d7ac

View File

@@ -0,0 +1,418 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using System.Threading;
using UnityEditor;
using UnityEngine;
namespace SplashEdit.EditorCode
{
/// <summary>
/// A live console window that displays stdout/stderr from PCSX-Redux and PSX build output.
/// Opens automatically when a build starts or the emulator launches.
/// </summary>
public class PSXConsoleWindow : EditorWindow
{
private const string WINDOW_TITLE = "PSX Console";
private const string MENU_PATH = "PlayStation 1/PSX Console";
private const int MAX_LINES = 2000;
private const int TRIM_AMOUNT = 500;
// ── Shared state (set by SplashControlPanel) ──
private static Process _process;
private static readonly List<LogLine> _lines = new List<LogLine>();
private static readonly object _lock = new object();
private static volatile bool _autoScroll = true;
private static volatile bool _reading;
// ── Instance state ──
private Vector2 _scrollPos;
private string _filterText = "";
private bool _showStdout = true;
private bool _showStderr = true;
private bool _wrapLines = true;
private GUIStyle _monoStyle;
private GUIStyle _monoStyleErr;
private GUIStyle _monoStyleSelected;
private int _lastLineCount;
// ── Selection state (for shift-click range and right-click copy) ──
private int _selectionAnchor = -1; // first clicked line index (into _lines)
private int _selectionEnd = -1; // last shift-clicked line index (into _lines)
private struct LogLine
{
public string text;
public bool isError;
public string timestamp;
}
// ═══════════════════════════════════════════════════════════════
// Menu
// ═══════════════════════════════════════════════════════════════
[MenuItem(MENU_PATH, false, 10)]
public static void ShowWindow()
{
var window = GetWindow<PSXConsoleWindow>();
window.titleContent = new GUIContent(WINDOW_TITLE, EditorGUIUtility.IconContent("d_UnityEditor.ConsoleWindow").image);
window.minSize = new Vector2(400, 200);
window.Show();
}
// ═══════════════════════════════════════════════════════════════
// Public API — called by SplashControlPanel
// ═══════════════════════════════════════════════════════════════
/// <summary>
/// Adds a line to the console from any source (serial host, emulator fallback, etc.).
/// Thread-safe. Works whether the window is open or not.
/// </summary>
public static void AddLine(string text, bool isError = false)
{
if (string.IsNullOrEmpty(text)) return;
lock (_lock)
{
_lines.Add(new LogLine
{
text = text,
isError = isError,
timestamp = DateTime.Now.ToString("HH:mm:ss.fff")
});
if (_lines.Count > MAX_LINES)
{
_lines.RemoveRange(0, TRIM_AMOUNT);
}
}
// Repaint is handled by OnEditorUpdate polling _lines.Count changes.
// Do NOT call EditorApplication.delayCall here - AddLine is called
// from background threads (serial host, process readers) and
// delayCall is not thread-safe. It kills the calling thread.
}
/// <summary>
/// Opens the console window and begins capturing output from the given process.
/// The process must have RedirectStandardOutput and RedirectStandardError enabled.
/// </summary>
public static PSXConsoleWindow Attach(Process process)
{
// Stop reading from any previous process (but keep existing lines)
_reading = false;
_process = process;
var window = GetWindow<PSXConsoleWindow>();
window.titleContent = new GUIContent(WINDOW_TITLE, EditorGUIUtility.IconContent("d_UnityEditor.ConsoleWindow").image);
window.minSize = new Vector2(400, 200);
window.Show();
// Start async readers
_reading = true;
StartReader(process.StandardOutput, false);
StartReader(process.StandardError, true);
return window;
}
/// <summary>
/// Stops reading and detaches from the current process.
/// </summary>
public static void Detach()
{
_reading = false;
_process = null;
}
// ═══════════════════════════════════════════════════════════════
// Async readers
// ═══════════════════════════════════════════════════════════════
private static void StartReader(System.IO.StreamReader reader, bool isError)
{
var thread = new Thread(() =>
{
try
{
while (_reading && !reader.EndOfStream)
{
string line = reader.ReadLine();
if (line == null) break;
lock (_lock)
{
_lines.Add(new LogLine
{
text = line,
isError = isError,
timestamp = DateTime.Now.ToString("HH:mm:ss.fff")
});
// Trim if too many lines
if (_lines.Count > MAX_LINES)
{
_lines.RemoveRange(0, TRIM_AMOUNT);
}
}
}
}
catch (Exception)
{
// Stream closed — normal when process exits
}
})
{
IsBackground = true,
Name = isError ? "PSXConsole-stderr" : "PSXConsole-stdout"
};
thread.Start();
}
// ═══════════════════════════════════════════════════════════════
// Window lifecycle
// ═══════════════════════════════════════════════════════════════
private void OnEnable()
{
EditorApplication.update += OnEditorUpdate;
}
private void OnDisable()
{
EditorApplication.update -= OnEditorUpdate;
}
private void OnEditorUpdate()
{
// Repaint when new lines arrive
int count;
lock (_lock) { count = _lines.Count; }
if (count != _lastLineCount)
{
_lastLineCount = count;
Repaint();
}
}
// ═══════════════════════════════════════════════════════════════
// GUI
// ═══════════════════════════════════════════════════════════════
private void EnsureStyles()
{
if (_monoStyle == null)
{
_monoStyle = new GUIStyle(EditorStyles.label)
{
font = Font.CreateDynamicFontFromOSFont("Consolas", 12),
fontSize = 11,
richText = false,
wordWrap = _wrapLines,
normal = { textColor = new Color(0.85f, 0.85f, 0.85f) },
padding = new RectOffset(4, 4, 1, 1),
margin = new RectOffset(0, 0, 0, 0)
};
}
if (_monoStyleErr == null)
{
_monoStyleErr = new GUIStyle(_monoStyle)
{
normal = { textColor = new Color(1f, 0.45f, 0.4f) }
};
}
if (_monoStyleSelected == null)
{
_monoStyleSelected = new GUIStyle(_monoStyle)
{
normal =
{
textColor = new Color(0.95f, 0.95f, 0.95f),
background = MakeSolidTexture(new Color(0.25f, 0.40f, 0.65f, 0.6f))
}
};
}
_monoStyle.wordWrap = _wrapLines;
_monoStyleErr.wordWrap = _wrapLines;
_monoStyleSelected.wordWrap = _wrapLines;
}
private static Texture2D MakeSolidTexture(Color color)
{
var tex = new Texture2D(1, 1);
tex.SetPixel(0, 0, color);
tex.Apply();
return tex;
}
private void OnGUI()
{
EnsureStyles();
DrawToolbar();
DrawConsoleOutput();
}
private void DrawToolbar()
{
EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);
// Process status
bool alive = _process != null && !_process.HasExited;
var statusColor = GUI.contentColor;
GUI.contentColor = alive ? Color.green : Color.gray;
GUILayout.Label(alive ? "● Live" : "● Stopped", EditorStyles.toolbarButton, GUILayout.Width(60));
GUI.contentColor = statusColor;
// Filter
GUILayout.Label("Filter:", GUILayout.Width(40));
_filterText = EditorGUILayout.TextField(_filterText, EditorStyles.toolbarSearchField, GUILayout.Width(150));
GUILayout.FlexibleSpace();
// Toggles
_showStdout = GUILayout.Toggle(_showStdout, "stdout", EditorStyles.toolbarButton, GUILayout.Width(50));
_showStderr = GUILayout.Toggle(_showStderr, "stderr", EditorStyles.toolbarButton, GUILayout.Width(50));
_wrapLines = GUILayout.Toggle(_wrapLines, "Wrap", EditorStyles.toolbarButton, GUILayout.Width(40));
// Auto-scroll
_autoScroll = GUILayout.Toggle(_autoScroll, "Auto↓", EditorStyles.toolbarButton, GUILayout.Width(50));
// Clear
if (GUILayout.Button("Clear", EditorStyles.toolbarButton, GUILayout.Width(45)))
{
lock (_lock) { _lines.Clear(); }
}
// Copy all
if (GUILayout.Button("Copy", EditorStyles.toolbarButton, GUILayout.Width(40)))
{
CopyToClipboard();
}
EditorGUILayout.EndHorizontal();
}
private void DrawConsoleOutput()
{
// Simple scroll view - no BeginArea/EndArea mixing that causes layout errors.
_scrollPos = EditorGUILayout.BeginScrollView(_scrollPos, GUILayout.ExpandHeight(true));
// Dark background behind the scroll content
Rect scrollBg = EditorGUILayout.BeginVertical();
EditorGUI.DrawRect(scrollBg, new Color(0.13f, 0.13f, 0.15f));
bool hasFilter = !string.IsNullOrEmpty(_filterText);
string filterLower = hasFilter ? _filterText.ToLowerInvariant() : null;
int selMin = Mathf.Min(_selectionAnchor, _selectionEnd);
int selMax = Mathf.Max(_selectionAnchor, _selectionEnd);
bool hasSelection = _selectionAnchor >= 0 && _selectionEnd >= 0;
lock (_lock)
{
if (_lines.Count == 0)
{
GUILayout.Label("Waiting for output...", EditorStyles.centeredGreyMiniLabel);
}
for (int i = 0; i < _lines.Count; i++)
{
var line = _lines[i];
if (line.isError && !_showStderr) continue;
if (!line.isError && !_showStdout) continue;
if (hasFilter && line.text.ToLowerInvariant().IndexOf(filterLower, StringComparison.Ordinal) < 0)
continue;
bool selected = hasSelection && i >= selMin && i <= selMax;
GUIStyle style = selected ? _monoStyleSelected : (line.isError ? _monoStyleErr : _monoStyle);
string label = $"[{line.timestamp}] {line.text}";
GUILayout.Label(label, style);
// Handle click/right-click on last drawn rect
Rect lineRect = GUILayoutUtility.GetLastRect();
Event evt = Event.current;
if (evt.type == EventType.MouseDown && lineRect.Contains(evt.mousePosition))
{
if (evt.button == 0)
{
if (evt.shift && _selectionAnchor >= 0)
_selectionEnd = i;
else
{
_selectionAnchor = i;
_selectionEnd = i;
}
evt.Use();
Repaint();
}
else if (evt.button == 1)
{
int clickedLine = i;
bool lineInSelection = hasSelection && clickedLine >= selMin && clickedLine <= selMax;
var menu = new GenericMenu();
if (lineInSelection && selMin != selMax)
{
menu.AddItem(new GUIContent("Copy selected lines"), false, () => CopyRange(selMin, selMax));
menu.AddSeparator("");
}
menu.AddItem(new GUIContent("Copy this line"), false, () =>
{
string text;
lock (_lock)
{
text = clickedLine < _lines.Count
? $"[{_lines[clickedLine].timestamp}] {_lines[clickedLine].text}"
: "";
}
EditorGUIUtility.systemCopyBuffer = text;
});
menu.ShowAsContext();
evt.Use();
}
}
}
}
EditorGUILayout.EndVertical();
if (_autoScroll)
_scrollPos.y = float.MaxValue;
EditorGUILayout.EndScrollView();
}
private void CopyRange(int fromIndex, int toIndex)
{
var sb = new StringBuilder();
lock (_lock)
{
int lo = Mathf.Min(fromIndex, toIndex);
int hi = Mathf.Max(fromIndex, toIndex);
for (int i = lo; i <= hi && i < _lines.Count; i++)
{
string prefix = _lines[i].isError ? "[ERR]" : "[OUT]";
sb.AppendLine($"[{_lines[i].timestamp}] {prefix} {_lines[i].text}");
}
}
EditorGUIUtility.systemCopyBuffer = sb.ToString();
}
private void CopyToClipboard()
{
var sb = new StringBuilder();
lock (_lock)
{
foreach (var line in _lines)
{
string prefix = line.isError ? "[ERR]" : "[OUT]";
sb.AppendLine($"[{line.timestamp}] {prefix} {line.text}");
}
}
EditorGUIUtility.systemCopyBuffer = sb.ToString();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c4e13fc5b859ac14099eb9f259ba11f0

View File

@@ -0,0 +1,777 @@
using UnityEngine;
using UnityEditor;
using System.Collections.Generic;
namespace SplashEdit.EditorCode
{
/// <summary>
/// Unified styling system for PSX Splash editor windows.
/// Provides consistent colors, fonts, icons, and GUIStyles across the entire plugin.
/// </summary>
[InitializeOnLoad]
public static class PSXEditorStyles
{
static PSXEditorStyles()
{
AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload;
}
private static void OnBeforeAssemblyReload()
{
foreach (var tex in _textureCache.Values)
{
if (tex != null)
Object.DestroyImmediate(tex);
}
_textureCache.Clear();
_styleCache.Clear();
}
#region Colors - PS1 Inspired Palette
// Primary colors
public static readonly Color PrimaryBlue = new Color(0.15f, 0.35f, 0.65f);
public static readonly Color PrimaryDark = new Color(0.12f, 0.12f, 0.14f);
public static readonly Color PrimaryLight = new Color(0.22f, 0.22f, 0.25f);
// Accent colors
public static readonly Color AccentGold = new Color(0.95f, 0.75f, 0.2f);
public static readonly Color AccentCyan = new Color(0.3f, 0.85f, 0.95f);
public static readonly Color AccentMagenta = new Color(0.85f, 0.3f, 0.65f);
public static readonly Color AccentGreen = new Color(0.35f, 0.85f, 0.45f);
// Semantic colors
public static readonly Color Success = new Color(0.35f, 0.8f, 0.4f);
public static readonly Color Warning = new Color(0.95f, 0.75f, 0.2f);
public static readonly Color Error = new Color(0.9f, 0.3f, 0.35f);
public static readonly Color Info = new Color(0.4f, 0.7f, 0.95f);
// Background colors
public static readonly Color BackgroundDark = new Color(0.15f, 0.15f, 0.17f);
public static readonly Color BackgroundMedium = new Color(0.2f, 0.2f, 0.22f);
public static readonly Color BackgroundLight = new Color(0.28f, 0.28f, 0.3f);
public static readonly Color BackgroundHighlight = new Color(0.25f, 0.35f, 0.5f);
// Text colors
public static readonly Color TextPrimary = new Color(0.9f, 0.9f, 0.92f);
public static readonly Color TextSecondary = new Color(0.65f, 0.65f, 0.7f);
public static readonly Color TextMuted = new Color(0.45f, 0.45f, 0.5f);
// VRAM specific colors
public static readonly Color VRAMFrameBuffer1 = new Color(1f, 0.3f, 0.3f, 0.4f);
public static readonly Color VRAMFrameBuffer2 = new Color(0.3f, 1f, 0.3f, 0.4f);
public static readonly Color VRAMProhibited = new Color(1f, 0f, 0f, 0.25f);
public static readonly Color VRAMTexture = new Color(0.3f, 0.6f, 1f, 0.5f);
public static readonly Color VRAMCLUT = new Color(1f, 0.6f, 0.3f, 0.5f);
#endregion
#region Cached Styles
private static Dictionary<string, GUIStyle> _styleCache = new Dictionary<string, GUIStyle>();
private static Dictionary<string, Texture2D> _textureCache = new Dictionary<string, Texture2D>();
#endregion
#region Textures
public static Texture2D GetSolidTexture(Color color)
{
string key = $"solid_{color.r}_{color.g}_{color.b}_{color.a}";
if (!_textureCache.TryGetValue(key, out var tex) || tex == null)
{
tex = new Texture2D(1, 1);
tex.SetPixel(0, 0, color);
tex.Apply();
tex.hideFlags = HideFlags.HideAndDontSave;
_textureCache[key] = tex;
}
return tex;
}
public static Texture2D CreateGradientTexture(int width, int height, Color top, Color bottom)
{
Texture2D tex = new Texture2D(width, height);
for (int y = 0; y < height; y++)
{
Color c = Color.Lerp(bottom, top, (float)y / height);
for (int x = 0; x < width; x++)
{
tex.SetPixel(x, y, c);
}
}
tex.Apply();
tex.hideFlags = HideFlags.HideAndDontSave;
return tex;
}
public static Texture2D CreateRoundedRect(int width, int height, int radius, Color fillColor, Color borderColor, int borderWidth = 1)
{
Texture2D tex = new Texture2D(width, height);
Color transparent = new Color(0, 0, 0, 0);
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
// Check if pixel is within rounded corners
bool inCorner = false;
float dist = 0;
// Top-left
if (x < radius && y > height - radius - 1)
{
dist = Vector2.Distance(new Vector2(x, y), new Vector2(radius, height - radius - 1));
inCorner = true;
}
// Top-right
else if (x > width - radius - 1 && y > height - radius - 1)
{
dist = Vector2.Distance(new Vector2(x, y), new Vector2(width - radius - 1, height - radius - 1));
inCorner = true;
}
// Bottom-left
else if (x < radius && y < radius)
{
dist = Vector2.Distance(new Vector2(x, y), new Vector2(radius, radius));
inCorner = true;
}
// Bottom-right
else if (x > width - radius - 1 && y < radius)
{
dist = Vector2.Distance(new Vector2(x, y), new Vector2(width - radius - 1, radius));
inCorner = true;
}
if (inCorner)
{
if (dist > radius)
tex.SetPixel(x, y, transparent);
else if (dist > radius - borderWidth)
tex.SetPixel(x, y, borderColor);
else
tex.SetPixel(x, y, fillColor);
}
else
{
// Check border
if (x < borderWidth || x >= width - borderWidth || y < borderWidth || y >= height - borderWidth)
tex.SetPixel(x, y, borderColor);
else
tex.SetPixel(x, y, fillColor);
}
}
}
tex.Apply();
tex.hideFlags = HideFlags.HideAndDontSave;
return tex;
}
#endregion
#region GUIStyles
private static GUIStyle _windowHeader;
public static GUIStyle WindowHeader
{
get
{
if (_windowHeader == null)
{
_windowHeader = new GUIStyle(EditorStyles.boldLabel)
{
fontSize = 18,
alignment = TextAnchor.MiddleLeft,
padding = new RectOffset(10, 10, 8, 8),
margin = new RectOffset(0, 0, 0, 5)
};
_windowHeader.normal.textColor = TextPrimary;
}
return _windowHeader;
}
}
private static GUIStyle _sectionHeader;
public static GUIStyle SectionHeader
{
get
{
if (_sectionHeader == null)
{
_sectionHeader = new GUIStyle(EditorStyles.boldLabel)
{
fontSize = 14,
alignment = TextAnchor.MiddleLeft,
padding = new RectOffset(5, 5, 8, 8),
margin = new RectOffset(0, 0, 10, 5)
};
_sectionHeader.normal.textColor = TextPrimary;
}
return _sectionHeader;
}
}
private static GUIStyle _cardStyle;
public static GUIStyle CardStyle
{
get
{
if (_cardStyle == null)
{
_cardStyle = new GUIStyle()
{
padding = new RectOffset(12, 12, 10, 10),
margin = new RectOffset(5, 5, 5, 5),
normal = { background = GetSolidTexture(BackgroundMedium) }
};
}
return _cardStyle;
}
}
private static GUIStyle _cardHeaderStyle;
public static GUIStyle CardHeaderStyle
{
get
{
if (_cardHeaderStyle == null)
{
_cardHeaderStyle = new GUIStyle(EditorStyles.boldLabel)
{
fontSize = 13,
padding = new RectOffset(0, 0, 0, 5),
margin = new RectOffset(0, 0, 0, 5)
};
_cardHeaderStyle.normal.textColor = TextPrimary;
}
return _cardHeaderStyle;
}
}
private static GUIStyle _primaryButton;
public static GUIStyle PrimaryButton
{
get
{
if (_primaryButton == null)
{
_primaryButton = new GUIStyle(GUI.skin.button)
{
fontSize = 12,
fontStyle = FontStyle.Bold,
padding = new RectOffset(15, 15, 8, 8),
margin = new RectOffset(5, 5, 5, 5),
alignment = TextAnchor.MiddleCenter
};
_primaryButton.normal.textColor = Color.white;
_primaryButton.normal.background = GetSolidTexture(PrimaryBlue);
_primaryButton.hover.background = GetSolidTexture(PrimaryBlue * 1.2f);
_primaryButton.active.background = GetSolidTexture(PrimaryBlue * 0.8f);
}
return _primaryButton;
}
}
private static GUIStyle _secondaryButton;
public static GUIStyle SecondaryButton
{
get
{
if (_secondaryButton == null)
{
_secondaryButton = new GUIStyle(GUI.skin.button)
{
fontSize = 11,
padding = new RectOffset(12, 12, 6, 6),
margin = new RectOffset(3, 3, 3, 3),
alignment = TextAnchor.MiddleCenter
};
_secondaryButton.normal.textColor = TextPrimary;
_secondaryButton.normal.background = GetSolidTexture(BackgroundLight);
_secondaryButton.hover.background = GetSolidTexture(BackgroundLight * 1.3f);
_secondaryButton.active.background = GetSolidTexture(BackgroundLight * 0.7f);
}
return _secondaryButton;
}
}
private static GUIStyle _successButton;
public static GUIStyle SuccessButton
{
get
{
if (_successButton == null)
{
_successButton = new GUIStyle(PrimaryButton);
_successButton.normal.background = GetSolidTexture(Success * 0.8f);
_successButton.hover.background = GetSolidTexture(Success);
_successButton.active.background = GetSolidTexture(Success * 0.6f);
}
return _successButton;
}
}
private static GUIStyle _dangerButton;
public static GUIStyle DangerButton
{
get
{
if (_dangerButton == null)
{
_dangerButton = new GUIStyle(PrimaryButton);
_dangerButton.normal.background = GetSolidTexture(Error * 0.8f);
_dangerButton.hover.background = GetSolidTexture(Error);
_dangerButton.active.background = GetSolidTexture(Error * 0.6f);
}
return _dangerButton;
}
}
private static GUIStyle _statusBadge;
public static GUIStyle StatusBadge
{
get
{
if (_statusBadge == null)
{
_statusBadge = new GUIStyle(EditorStyles.label)
{
fontSize = 10,
fontStyle = FontStyle.Bold,
alignment = TextAnchor.MiddleCenter,
padding = new RectOffset(8, 8, 3, 3),
margin = new RectOffset(3, 3, 3, 3)
};
}
return _statusBadge;
}
}
private static GUIStyle _toolbarStyle;
public static GUIStyle ToolbarStyle
{
get
{
if (_toolbarStyle == null)
{
_toolbarStyle = new GUIStyle()
{
padding = new RectOffset(8, 8, 6, 6),
margin = new RectOffset(0, 0, 0, 0),
normal = { background = GetSolidTexture(BackgroundDark) }
};
}
return _toolbarStyle;
}
}
private static GUIStyle _infoBox;
public static GUIStyle InfoBox
{
get
{
if (_infoBox == null)
{
_infoBox = new GUIStyle(EditorStyles.helpBox)
{
fontSize = 11,
padding = new RectOffset(10, 10, 8, 8),
margin = new RectOffset(5, 5, 5, 5),
richText = true
};
}
return _infoBox;
}
}
private static GUIStyle _centeredLabel;
public static GUIStyle CenteredLabel
{
get
{
if (_centeredLabel == null)
{
_centeredLabel = new GUIStyle(EditorStyles.label)
{
alignment = TextAnchor.MiddleCenter,
wordWrap = true
};
}
return _centeredLabel;
}
}
private static GUIStyle _richLabel;
public static GUIStyle RichLabel
{
get
{
if (_richLabel == null)
{
_richLabel = new GUIStyle(EditorStyles.label)
{
richText = true,
wordWrap = true
};
}
return _richLabel;
}
}
private static GUIStyle _foldoutHeader;
public static GUIStyle FoldoutHeader
{
get
{
if (_foldoutHeader == null)
{
_foldoutHeader = new GUIStyle(EditorStyles.foldout)
{
fontSize = 12,
fontStyle = FontStyle.Bold,
padding = new RectOffset(15, 0, 3, 3)
};
_foldoutHeader.normal.textColor = TextPrimary;
}
return _foldoutHeader;
}
}
#endregion
#region Drawing Helpers
/// <summary>
/// Draw a horizontal separator line
/// </summary>
public static void DrawSeparator(float topMargin = 5, float bottomMargin = 5)
{
GUILayout.Space(topMargin);
var rect = GUILayoutUtility.GetRect(1, 1, GUILayout.ExpandWidth(true));
EditorGUI.DrawRect(rect, TextMuted * 0.5f);
GUILayout.Space(bottomMargin);
}
/// <summary>
/// Draw a status badge with color
/// </summary>
public static void DrawStatusBadge(string text, Color color, float width = 80)
{
var style = new GUIStyle(StatusBadge);
style.normal.background = GetSolidTexture(color);
style.normal.textColor = GetContrastColor(color);
GUILayout.Label(text, style, GUILayout.Width(width));
}
/// <summary>
/// Draw a progress bar
/// </summary>
public static void DrawProgressBar(float progress, string label, Color fillColor, float height = 20)
{
var rect = GUILayoutUtility.GetRect(100, height, GUILayout.ExpandWidth(true));
// Background
EditorGUI.DrawRect(rect, BackgroundDark);
// Fill
var fillRect = new Rect(rect.x, rect.y, rect.width * Mathf.Clamp01(progress), rect.height);
EditorGUI.DrawRect(fillRect, fillColor);
// Border
DrawBorder(rect, TextMuted * 0.5f, 1);
// Label
var labelStyle = new GUIStyle(EditorStyles.label)
{
alignment = TextAnchor.MiddleCenter,
normal = { textColor = TextPrimary }
};
GUI.Label(rect, $"{label} ({progress * 100:F0}%)", labelStyle);
}
/// <summary>
/// Draw a border around a rect
/// </summary>
public static void DrawBorder(Rect rect, Color color, int thickness = 1)
{
// Top
EditorGUI.DrawRect(new Rect(rect.x, rect.y, rect.width, thickness), color);
// Bottom
EditorGUI.DrawRect(new Rect(rect.x, rect.yMax - thickness, rect.width, thickness), color);
// Left
EditorGUI.DrawRect(new Rect(rect.x, rect.y, thickness, rect.height), color);
// Right
EditorGUI.DrawRect(new Rect(rect.xMax - thickness, rect.y, thickness, rect.height), color);
}
/// <summary>
/// Get a contrasting text color for a background
/// </summary>
public static Color GetContrastColor(Color background)
{
float luminance = 0.299f * background.r + 0.587f * background.g + 0.114f * background.b;
return luminance > 0.5f ? Color.black : Color.white;
}
/// <summary>
/// Begin a styled card section
/// </summary>
public static void BeginCard()
{
EditorGUILayout.BeginVertical(CardStyle);
}
/// <summary>
/// End a styled card section
/// </summary>
public static void EndCard()
{
EditorGUILayout.EndVertical();
}
/// <summary>
/// Draw a card with header and content
/// </summary>
public static bool DrawFoldoutCard(string title, bool isExpanded, System.Action drawContent)
{
EditorGUILayout.BeginVertical(CardStyle);
EditorGUILayout.BeginHorizontal();
isExpanded = EditorGUILayout.Foldout(isExpanded, title, true, FoldoutHeader);
EditorGUILayout.EndHorizontal();
if (isExpanded)
{
EditorGUILayout.Space(5);
drawContent?.Invoke();
}
EditorGUILayout.EndVertical();
return isExpanded;
}
/// <summary>
/// Draw a large icon button (for dashboard)
/// </summary>
public static bool DrawIconButton(string label, string icon, string description, float width = 150, float height = 100)
{
var rect = GUILayoutUtility.GetRect(width, height);
bool isHover = rect.Contains(Event.current.mousePosition);
var bgColor = isHover ? BackgroundHighlight : BackgroundMedium;
EditorGUI.DrawRect(rect, bgColor);
DrawBorder(rect, isHover ? AccentCyan : TextMuted * 0.3f, 1);
// Icon (using Unity's built-in icons or a placeholder)
var iconRect = new Rect(rect.x + rect.width / 2 - 16, rect.y + 15, 32, 32);
var iconContent = EditorGUIUtility.IconContent(icon);
if (iconContent != null && iconContent.image != null)
{
GUI.DrawTexture(iconRect, iconContent.image);
}
// Label
var labelRect = new Rect(rect.x, rect.y + 52, rect.width, 20);
var labelStyle = new GUIStyle(EditorStyles.boldLabel)
{
alignment = TextAnchor.MiddleCenter,
normal = { textColor = TextPrimary }
};
GUI.Label(labelRect, label, labelStyle);
// Description
var descRect = new Rect(rect.x + 5, rect.y + 70, rect.width - 10, 25);
var descStyle = new GUIStyle(EditorStyles.miniLabel)
{
alignment = TextAnchor.UpperCenter,
wordWrap = true,
normal = { textColor = TextSecondary }
};
GUI.Label(descRect, description, descStyle);
return GUI.Button(rect, GUIContent.none, GUIStyle.none);
}
/// <summary>
/// Draw a horizontal button group
/// </summary>
public static int DrawButtonGroup(string[] labels, int selected, float height = 25)
{
EditorGUILayout.BeginHorizontal();
for (int i = 0; i < labels.Length; i++)
{
bool isSelected = i == selected;
var style = new GUIStyle(GUI.skin.button)
{
fontStyle = isSelected ? FontStyle.Bold : FontStyle.Normal
};
if (isSelected)
{
style.normal.background = GetSolidTexture(PrimaryBlue);
style.normal.textColor = Color.white;
}
else
{
style.normal.background = GetSolidTexture(BackgroundLight);
style.normal.textColor = TextSecondary;
}
if (GUILayout.Button(labels[i], style, GUILayout.Height(height)))
{
selected = i;
}
}
EditorGUILayout.EndHorizontal();
return selected;
}
#endregion
#region Layout Helpers
/// <summary>
/// Begin a toolbar row
/// </summary>
public static void BeginToolbar()
{
EditorGUILayout.BeginHorizontal(ToolbarStyle);
}
/// <summary>
/// End a toolbar row
/// </summary>
public static void EndToolbar()
{
EditorGUILayout.EndHorizontal();
}
/// <summary>
/// Add flexible space
/// </summary>
public static void FlexibleSpace()
{
GUILayout.FlexibleSpace();
}
/// <summary>
/// Begin a centered layout
/// </summary>
public static void BeginCentered()
{
EditorGUILayout.BeginHorizontal();
GUILayout.FlexibleSpace();
EditorGUILayout.BeginVertical();
}
/// <summary>
/// End a centered layout
/// </summary>
public static void EndCentered()
{
EditorGUILayout.EndVertical();
GUILayout.FlexibleSpace();
EditorGUILayout.EndHorizontal();
}
#endregion
#region Cleanup
/// <summary>
/// Clear cached styles and textures. Call when recompiling.
/// </summary>
public static void ClearCache()
{
foreach (var tex in _textureCache.Values)
{
if (tex != null)
Object.DestroyImmediate(tex);
}
_textureCache.Clear();
_windowHeader = null;
_sectionHeader = null;
_cardStyle = null;
_cardHeaderStyle = null;
_primaryButton = null;
_secondaryButton = null;
_successButton = null;
_dangerButton = null;
_statusBadge = null;
_toolbarStyle = null;
_infoBox = null;
_centeredLabel = null;
_richLabel = null;
_foldoutHeader = null;
}
#endregion
}
/// <summary>
/// Icons used throughout the PSX Splash editor
/// </summary>
public static class PSXIcons
{
// Unity built-in icons that work well for our purposes
public const string Scene = "d_SceneAsset Icon";
public const string Build = "d_BuildSettings.SelectedIcon";
public const string Settings = "d_Settings";
public const string Play = "d_PlayButton";
public const string Refresh = "d_Refresh";
public const string Warning = "d_console.warnicon";
public const string Error = "d_console.erroricon";
public const string Info = "d_console.infoicon";
public const string Success = "d_Progress";
public const string Texture = "d_Texture Icon";
public const string Mesh = "d_Mesh Icon";
public const string Script = "d_cs Script Icon";
public const string Folder = "d_Folder Icon";
public const string Download = "d_Download-Available";
public const string Upload = "d_UpArrow";
public const string Link = "d_Linked";
public const string Unlink = "d_Unlinked";
public const string Eye = "d_scenevis_visible_hover";
public const string EyeOff = "d_scenevis_hidden_hover";
public const string Add = "d_Toolbar Plus";
public const string Remove = "d_Toolbar Minus";
public const string Edit = "d_editicon.sml";
public const string Search = "d_Search Icon";
public const string Console = "d_UnityEditor.ConsoleWindow";
public const string Help = "d__Help";
public const string GameObject = "d_GameObject Icon";
public const string Camera = "d_Camera Icon";
public const string Light = "d_Light Icon";
public const string Prefab = "d_Prefab Icon";
/// <summary>
/// Get a GUIContent with icon and tooltip
/// </summary>
public static GUIContent GetContent(string icon, string tooltip = "")
{
var content = EditorGUIUtility.IconContent(icon);
if (content == null) content = new GUIContent();
content.tooltip = tooltip;
return content;
}
/// <summary>
/// Get a GUIContent with icon, text and tooltip
/// </summary>
public static GUIContent GetContent(string icon, string text, string tooltip)
{
var content = EditorGUIUtility.IconContent(icon);
if (content == null) content = new GUIContent();
content.text = text;
content.tooltip = tooltip;
return content;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8aefa79a412d32c4f8bc8249bb4cd118

View File

@@ -0,0 +1,178 @@
using System.IO;
using UnityEngine;
namespace SplashEdit.EditorCode
{
/// <summary>
/// Manages all build-related paths for the SplashEdit pipeline.
/// All output goes outside Assets/ to avoid Unity import overhead.
/// </summary>
public static class SplashBuildPaths
{
/// <summary>
/// The build output directory at the Unity project root.
/// Contains exported splashpacks, manifest, compiled .ps-exe, ISO, build log.
/// </summary>
public static string BuildOutputDir =>
Path.Combine(ProjectRoot, "PSXBuild");
/// <summary>
/// The tools directory at the Unity project root.
/// Contains auto-downloaded tools like PCSX-Redux.
/// </summary>
public static string ToolsDir =>
Path.Combine(ProjectRoot, ".tools");
/// <summary>
/// PCSX-Redux install directory inside .tools/.
/// </summary>
public static string PCSXReduxDir =>
Path.Combine(ToolsDir, "pcsx-redux");
/// <summary>
/// Platform-specific PCSX-Redux binary path.
/// </summary>
public static string PCSXReduxBinary
{
get
{
switch (Application.platform)
{
case RuntimePlatform.WindowsEditor:
return Path.Combine(PCSXReduxDir, "pcsx-redux.exe");
case RuntimePlatform.LinuxEditor:
return Path.Combine(ToolsDir, "PCSX-Redux-HEAD-x86_64.AppImage");
default:
return Path.Combine(PCSXReduxDir, "pcsx-redux");
}
}
}
/// <summary>
/// The Unity project root (parent of Assets/).
/// </summary>
public static string ProjectRoot =>
Directory.GetParent(Application.dataPath).FullName;
/// <summary>
/// Path to the native psxsplash source.
/// First checks SplashSettings override, then looks for common locations.
/// </summary>
public static string NativeSourceDir
{
get
{
// 1. Check the user-configured path from SplashSettings
string custom = SplashSettings.NativeProjectPath;
if (!string.IsNullOrEmpty(custom) && Directory.Exists(custom))
return custom;
// 2. Look inside the Unity project's Assets/ folder (git clone location)
string assetsClone = Path.Combine(UnityEngine.Application.dataPath, "psxsplash");
if (Directory.Exists(assetsClone) && File.Exists(Path.Combine(assetsClone, "Makefile")))
return assetsClone;
// 3. Look for Native/ inside the package
string packageNative = Path.GetFullPath(
Path.Combine("Packages", "net.psxsplash.splashedit", "Native"));
if (Directory.Exists(packageNative))
return packageNative;
return "";
}
}
/// <summary>
/// The compiled .ps-exe output from the native build.
/// </summary>
public static string CompiledExePath =>
Path.Combine(BuildOutputDir, "psxsplash.ps-exe");
/// <summary>
/// The scene manifest file path.
/// </summary>
public static string ManifestPath =>
Path.Combine(BuildOutputDir, "manifest.bin");
/// <summary>
/// Build log file path.
/// </summary>
public static string BuildLogPath =>
Path.Combine(BuildOutputDir, "build.log");
/// <summary>
/// Gets the splashpack output path for a scene by index.
/// Uses a deterministic naming scheme: scene_0.splashpack, scene_1.splashpack, etc.
/// </summary>
public static string GetSceneSplashpackPath(int sceneIndex, string sceneName)
{
return Path.Combine(BuildOutputDir, $"scene_{sceneIndex}.splashpack");
}
/// <summary>
/// ISO output path for release builds.
/// </summary>
public static string ISOOutputPath =>
Path.Combine(BuildOutputDir, "psxsplash.bin");
/// <summary>
/// CUE sheet path for release builds.
/// </summary>
public static string CUEOutputPath =>
Path.Combine(BuildOutputDir, "psxsplash.cue");
/// <summary>
/// Ensures the build output and tools directories exist.
/// Also appends entries to the project .gitignore if not present.
/// </summary>
public static void EnsureDirectories()
{
Directory.CreateDirectory(BuildOutputDir);
Directory.CreateDirectory(ToolsDir);
EnsureGitIgnore();
}
/// <summary>
/// Checks if PCSX-Redux is installed in the tools directory.
/// </summary>
public static bool IsPCSXReduxInstalled()
{
return File.Exists(PCSXReduxBinary);
}
private static void EnsureGitIgnore()
{
string gitignorePath = Path.Combine(ProjectRoot, ".gitignore");
string[] entriesToAdd = new[] { "/PSXBuild/", "/.tools/" };
string existingContent = "";
if (File.Exists(gitignorePath))
{
existingContent = File.ReadAllText(gitignorePath);
}
bool modified = false;
string toAppend = "";
foreach (string entry in entriesToAdd)
{
// Check if entry already exists (exact line match)
if (!existingContent.Contains(entry))
{
if (!modified)
{
toAppend += "\n# SplashEdit build output\n";
modified = true;
}
toAppend += entry + "\n";
}
}
if (modified)
{
File.AppendAllText(gitignorePath, toAppend);
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3988772ca929eb14ea3bee6b643de4d0

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5540e6cbefeb70d48a0c1e3843719784

View File

@@ -0,0 +1,159 @@
using UnityEditor;
using UnityEngine;
namespace SplashEdit.EditorCode
{
/// <summary>
/// Enumerates the pipeline target for builds.
/// </summary>
public enum BuildTarget
{
Emulator, // PCSX-Redux with PCdrv
RealHardware, // Send .ps-exe over serial via Unirom
ISO // Build a CD image
}
/// <summary>
/// Enumerates the build configuration.
/// </summary>
public enum BuildMode
{
Debug,
Release
}
/// <summary>
/// Centralized EditorPrefs-backed settings for the SplashEdit pipeline.
/// All settings are project-scoped using a prefix derived from the project path.
/// </summary>
public static class SplashSettings
{
// Prefix all keys with project path hash to support multiple projects
internal static string Prefix => "SplashEdit_" + Application.dataPath.GetHashCode().ToString("X8") + "_";
// --- Build settings ---
public static BuildTarget Target
{
get => (BuildTarget)EditorPrefs.GetInt(Prefix + "Target", (int)BuildTarget.Emulator);
set => EditorPrefs.SetInt(Prefix + "Target", (int)value);
}
public static BuildMode Mode
{
get => (BuildMode)EditorPrefs.GetInt(Prefix + "Mode", (int)BuildMode.Release);
set => EditorPrefs.SetInt(Prefix + "Mode", (int)value);
}
// --- Toolchain paths ---
public static string NativeProjectPath
{
get => EditorPrefs.GetString(Prefix + "NativeProjectPath", "");
set => EditorPrefs.SetString(Prefix + "NativeProjectPath", value);
}
public static string MIPSToolchainPath
{
get => EditorPrefs.GetString(Prefix + "MIPSToolchainPath", "");
set => EditorPrefs.SetString(Prefix + "MIPSToolchainPath", value);
}
// --- PCSX-Redux ---
public static string PCSXReduxPath
{
get
{
string custom = EditorPrefs.GetString(Prefix + "PCSXReduxPath", "");
if (!string.IsNullOrEmpty(custom))
return custom;
// Fall back to auto-downloaded location
if (SplashBuildPaths.IsPCSXReduxInstalled())
return SplashBuildPaths.PCSXReduxBinary;
return "";
}
set => EditorPrefs.SetString(Prefix + "PCSXReduxPath", value);
}
public static string PCSXReduxPCdrvBase
{
get => EditorPrefs.GetString(Prefix + "PCSXReduxPCdrvBase", SplashBuildPaths.BuildOutputDir);
set => EditorPrefs.SetString(Prefix + "PCSXReduxPCdrvBase", value);
}
// --- Serial / Real Hardware ---
public static string SerialPort
{
get => EditorPrefs.GetString(Prefix + "SerialPort", "COM3");
set => EditorPrefs.SetString(Prefix + "SerialPort", value);
}
public static int SerialBaudRate
{
get => EditorPrefs.GetInt(Prefix + "SerialBaudRate", 115200);
set => EditorPrefs.SetInt(Prefix + "SerialBaudRate", value);
}
// --- VRAM Layout ---
public static int ResolutionWidth
{
get => EditorPrefs.GetInt(Prefix + "ResWidth", 320);
set => EditorPrefs.SetInt(Prefix + "ResWidth", value);
}
public static int ResolutionHeight
{
get => EditorPrefs.GetInt(Prefix + "ResHeight", 240);
set => EditorPrefs.SetInt(Prefix + "ResHeight", value);
}
public static bool DualBuffering
{
get => EditorPrefs.GetBool(Prefix + "DualBuffering", true);
set => EditorPrefs.SetBool(Prefix + "DualBuffering", value);
}
public static bool VerticalLayout
{
get => EditorPrefs.GetBool(Prefix + "VerticalLayout", true);
set => EditorPrefs.SetBool(Prefix + "VerticalLayout", value);
}
// --- Export settings ---
public static float DefaultGTEScaling
{
get => EditorPrefs.GetFloat(Prefix + "GTEScaling", 100f);
set => EditorPrefs.SetFloat(Prefix + "GTEScaling", value);
}
public static bool AutoValidateOnExport
{
get => EditorPrefs.GetBool(Prefix + "AutoValidate", true);
set => EditorPrefs.SetBool(Prefix + "AutoValidate", value);
}
// --- Play Mode Intercept ---
public static bool InterceptPlayMode
{
get => EditorPrefs.GetBool(Prefix + "InterceptPlayMode", false);
set => EditorPrefs.SetBool(Prefix + "InterceptPlayMode", value);
}
/// <summary>
/// Resets all settings to defaults by deleting all prefixed keys.
/// </summary>
public static void ResetAll()
{
string[] keys = new[]
{
"Target", "Mode", "NativeProjectPath", "MIPSToolchainPath",
"PCSXReduxPath", "PCSXReduxPCdrvBase", "SerialPort", "SerialBaudRate",
"ResWidth", "ResHeight", "DualBuffering", "VerticalLayout",
"GTEScaling", "AutoValidate", "InterceptPlayMode"
};
foreach (string key in keys)
{
EditorPrefs.DeleteKey(Prefix + key);
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4765dbe728569d84699a22347e7c14ff

View File

@@ -0,0 +1,549 @@
using System;
using System.IO;
using System.IO.Ports;
using System.Text;
using System.Threading;
using UnityEngine;
namespace SplashEdit.EditorCode
{
/// <summary>
/// Uploads a .ps-exe to a PS1 running Unirom 8 via serial.
/// Implements the NOTPSXSerial / Unirom protocol:
/// Challenge/Response handshake → header → metadata → chunked data with checksums.
/// Reference: https://github.com/JonathanDotCel/NOTPSXSerial
/// </summary>
public static class UniromUploader
{
// Protocol constants
private const string CHALLENGE_SEND_EXE = "SEXE";
private const string RESPONSE_OK = "OKAY";
private const int CHUNK_SIZE = 2048;
private const int HEADER_SIZE = 0x800; // 2048
private const int SERIAL_TIMEOUT_MS = 5000;
// Protocol version — negotiated during handshake
private static int _protocolVersion = 1;
/// <summary>
/// Uploads a .ps-exe file to the PS1 via serial.
/// The PS1 must be at the Unirom shell prompt.
/// </summary>
public static bool UploadExe(string portName, int baudRate, string exePath, Action<string> log)
{
var port = DoUpload(portName, baudRate, exePath, log, installDebugHooks: false);
if (port == null) return false;
try { port.Close(); } catch { }
port.Dispose();
return true;
}
/// <summary>
/// Uploads a .ps-exe with Unirom debug hooks installed, using SBIN+JUMP
/// instead of SEXE to avoid BIOS Exec() clobbering the debug handler.
///
/// Flow: DEBG (install kernel-resident debug hooks) → SBIN (raw binary to address)
/// → JUMP (start execution at entry point). This bypasses BIOS Exec() entirely,
/// so the exception vector table patched by DEBG survives into the running program.
///
/// Returns the open SerialPort for the caller to use for PCDrv monitoring.
/// The caller takes ownership of the returned port.
/// </summary>
public static SerialPort UploadExeForPCdrv(string portName, int baudRate, string exePath, Action<string> log)
{
return DoUploadSBIN(portName, baudRate, exePath, log);
}
/// <summary>
/// Core SEXE upload implementation. Opens port, optionally sends DEBG, does SEXE upload.
/// Used by UploadExe() for simple uploads without PCDrv.
/// Returns the open SerialPort (caller must close/dispose when done).
/// Returns null on failure.
/// </summary>
private static SerialPort DoUpload(string portName, int baudRate, string exePath, Action<string> log, bool installDebugHooks)
{
if (!File.Exists(exePath))
{
log?.Invoke($"File not found: {exePath}");
return null;
}
byte[] exeData = File.ReadAllBytes(exePath);
log?.Invoke($"Uploading {Path.GetFileName(exePath)} ({exeData.Length} bytes)");
// Pad to 2048-byte sector boundary (required by Unirom)
int mod = exeData.Length % CHUNK_SIZE;
if (mod != 0)
{
int paddingRequired = CHUNK_SIZE - mod;
byte[] padded = new byte[exeData.Length + paddingRequired];
Buffer.BlockCopy(exeData, 0, padded, 0, exeData.Length);
exeData = padded;
log?.Invoke($"Padded to {exeData.Length} bytes (2048-byte boundary)");
}
_protocolVersion = 1;
SerialPort port = null;
try
{
port = new SerialPort(portName, baudRate)
{
ReadTimeout = SERIAL_TIMEOUT_MS,
WriteTimeout = SERIAL_TIMEOUT_MS,
StopBits = StopBits.Two,
Parity = Parity.None,
DataBits = 8,
Handshake = Handshake.None,
DtrEnable = true,
RtsEnable = true
};
port.Open();
// Drain any leftover bytes in the buffer
while (port.BytesToRead > 0)
port.ReadByte();
// ── Step 0 (PCDrv only): Install debug hooks while Unirom is still in command mode ──
if (installDebugHooks)
{
log?.Invoke("Installing debug hooks (DEBG)...");
if (!ChallengeResponse(port, "DEBG", "OKAY", log))
{
log?.Invoke("WARNING: DEBG failed. Is Unirom at the shell? PCDrv may not work.");
}
else
{
log?.Invoke("Debug hooks installed.");
}
Thread.Sleep(100);
while (port.BytesToRead > 0)
port.ReadByte();
}
// ── Step 1: Challenge/Response handshake ──
log?.Invoke("Sending SEXE challenge...");
if (!ChallengeResponse(port, CHALLENGE_SEND_EXE, RESPONSE_OK, log))
{
log?.Invoke("No response from Unirom. Is the PS1 at the Unirom shell?");
port.Close(); port.Dispose();
return null;
}
log?.Invoke($"Unirom responded (protocol V{_protocolVersion}). Starting transfer...");
// ── Step 2: Calculate checksum (skip first 0x800 header sector) ──
uint checksum = CalculateChecksum(exeData, skipFirstSector: true);
// ── Step 3: Send the 2048-byte header sector ──
port.Write(exeData, 0, HEADER_SIZE);
// ── Step 4: Send metadata ──
port.Write(exeData, 0x10, 4); // Jump/PC address
port.Write(exeData, 0x18, 4); // Base/write address
port.Write(BitConverter.GetBytes(exeData.Length - HEADER_SIZE), 0, 4); // Data length
port.Write(BitConverter.GetBytes(checksum), 0, 4); // Checksum
// ── Step 5: Send data chunks (skip first sector) ──
if (!WriteChunked(port, exeData, skipFirstSector: true, log))
{
log?.Invoke("Data transfer failed.");
port.Close(); port.Dispose();
return null;
}
log?.Invoke("Upload complete. Exe executing on PS1.");
return port;
}
catch (Exception ex)
{
log?.Invoke($"Upload failed: {ex.Message}");
if (port != null && port.IsOpen)
{
try { port.Close(); } catch { }
}
port?.Dispose();
return null;
}
}
/// <summary>
/// Uploads a .ps-exe using DEBG + SBIN + JUMP to preserve debug hooks.
///
/// Unlike SEXE which calls BIOS Exec() (reinitializing the exception vector table
/// and destroying DEBG's kernel-resident debug handler), SBIN writes raw bytes
/// directly to the target address and JUMP starts execution without touching
/// the BIOS. This preserves the break-instruction handler that PCDrv depends on.
///
/// Protocol:
/// 1. DEBG → OKAY: Install kernel-resident SIO debug stub
/// 2. SBIN → OKAY: addr(4 LE) + len(4 LE) + checksum(4 LE) + raw program data
/// 3. JUMP → OKAY: addr(4 LE) — jump to entry point
/// </summary>
private static SerialPort DoUploadSBIN(string portName, int baudRate, string exePath, Action<string> log)
{
if (!File.Exists(exePath))
{
log?.Invoke($"File not found: {exePath}");
return null;
}
byte[] exeData = File.ReadAllBytes(exePath);
log?.Invoke($"Uploading {Path.GetFileName(exePath)} ({exeData.Length} bytes) via SBIN+JUMP");
// Validate this is a PS-X EXE
if (exeData.Length < HEADER_SIZE + 4)
{
log?.Invoke("File too small to be a valid PS-X EXE.");
return null;
}
string magic = Encoding.ASCII.GetString(exeData, 0, 8);
if (!magic.StartsWith("PS-X EXE"))
{
log?.Invoke($"Not a PS-X EXE (magic: '{magic}')");
return null;
}
// Parse header
uint entryPoint = BitConverter.ToUInt32(exeData, 0x10); // PC / jump address
uint destAddr = BitConverter.ToUInt32(exeData, 0x18); // Copy destination
uint textSize = BitConverter.ToUInt32(exeData, 0x1C); // Text section size
log?.Invoke($"PS-X EXE: entry=0x{entryPoint:X8}, dest=0x{destAddr:X8}, textSz=0x{textSize:X}");
// Extract program data (everything after the 2048-byte header)
int progDataLen = exeData.Length - HEADER_SIZE;
byte[] progData = new byte[progDataLen];
Buffer.BlockCopy(exeData, HEADER_SIZE, progData, 0, progDataLen);
// Pad program data to 2048-byte boundary (required by Unirom chunked transfer)
int mod = progData.Length % CHUNK_SIZE;
if (mod != 0)
{
int paddingRequired = CHUNK_SIZE - mod;
byte[] padded = new byte[progData.Length + paddingRequired];
Buffer.BlockCopy(progData, 0, padded, 0, progData.Length);
progData = padded;
log?.Invoke($"Program data padded to {progData.Length} bytes");
}
_protocolVersion = 1;
SerialPort port = null;
try
{
port = new SerialPort(portName, baudRate)
{
ReadTimeout = SERIAL_TIMEOUT_MS,
WriteTimeout = SERIAL_TIMEOUT_MS,
StopBits = StopBits.Two,
Parity = Parity.None,
DataBits = 8,
Handshake = Handshake.None,
DtrEnable = true,
RtsEnable = true
};
port.Open();
// Drain any leftover bytes
while (port.BytesToRead > 0)
port.ReadByte();
// ── Step 1: DEBG — Install kernel-resident debug hooks ──
log?.Invoke("Installing debug hooks (DEBG)...");
if (!ChallengeResponse(port, "DEBG", "OKAY", log))
{
log?.Invoke("DEBG failed. Is Unirom at the shell?");
port.Close(); port.Dispose();
return null;
}
log?.Invoke("Debug hooks installed.");
// Drain + settle — Unirom may send extra bytes after DEBG
Thread.Sleep(100);
while (port.BytesToRead > 0)
port.ReadByte();
// ── Step 2: SBIN — Upload raw program data to target address ──
log?.Invoke($"Sending SBIN to 0x{destAddr:X8} ({progData.Length} bytes)...");
if (!ChallengeResponse(port, "SBIN", "OKAY", log))
{
log?.Invoke("SBIN failed. Unirom may not support this command.");
port.Close(); port.Dispose();
return null;
}
// SBIN metadata: address(4) + length(4) + checksum(4)
uint checksum = CalculateChecksum(progData, skipFirstSector: false);
port.Write(BitConverter.GetBytes(destAddr), 0, 4);
port.Write(BitConverter.GetBytes(progData.Length), 0, 4);
port.Write(BitConverter.GetBytes(checksum), 0, 4);
log?.Invoke($"SBIN metadata sent (checksum=0x{checksum:X8}). Sending data...");
// Send program data chunks
if (!WriteChunked(port, progData, skipFirstSector: false, log))
{
log?.Invoke("SBIN data transfer failed.");
port.Close(); port.Dispose();
return null;
}
log?.Invoke("SBIN upload complete.");
// Drain any residual
Thread.Sleep(100);
while (port.BytesToRead > 0)
port.ReadByte();
// ── Step 3: JUMP — Start execution at entry point ──
log?.Invoke($"Sending JUMP to 0x{entryPoint:X8}...");
if (!ChallengeResponse(port, "JUMP", "OKAY", log))
{
log?.Invoke("JUMP failed.");
port.Close(); port.Dispose();
return null;
}
// JUMP payload: just the address (4 bytes LE)
port.Write(BitConverter.GetBytes(entryPoint), 0, 4);
log?.Invoke("JUMP sent. Exe now running (debug hooks preserved).");
return port;
}
catch (Exception ex)
{
log?.Invoke($"Upload failed: {ex.Message}");
if (port != null && port.IsOpen)
{
try { port.Close(); } catch { }
}
port?.Dispose();
return null;
}
}
// ═══════════════════════════════════════════════════════════════
// Challenge / Response with protocol negotiation
// ═══════════════════════════════════════════════════════════════
private static bool ChallengeResponse(SerialPort port, string challenge, string expectedResponse, Action<string> log)
{
// Send the challenge
byte[] challengeBytes = Encoding.ASCII.GetBytes(challenge);
port.Write(challengeBytes, 0, challengeBytes.Length);
Thread.Sleep(50);
// Wait for the response with protocol negotiation
return WaitResponse(port, expectedResponse, log);
}
private static bool WaitResponse(SerialPort port, string expected, Action<string> log, int timeoutMs = 10000)
{
string buffer = "";
DateTime deadline = DateTime.Now.AddMilliseconds(timeoutMs);
while (DateTime.Now < deadline)
{
if (port.BytesToRead > 0)
{
buffer += (char)port.ReadByte();
// Keep buffer at 4 chars max (rolling window)
if (buffer.Length > 4)
buffer = buffer.Substring(buffer.Length - 4);
// Protocol V3 upgrade (DJB2 checksums)
// Always respond — Unirom re-offers V2/V3 for each command,
// and our protocolVersion may already be >1 from a prior DEBG exchange.
if (buffer == "OKV3")
{
log?.Invoke("Upgraded to protocol V3");
byte[] upv3 = Encoding.ASCII.GetBytes("UPV3");
port.Write(upv3, 0, upv3.Length);
_protocolVersion = 3;
buffer = "";
continue;
}
// Protocol V2 upgrade (per-chunk checksums)
if (buffer == "OKV2")
{
log?.Invoke("Upgraded to protocol V2");
byte[] upv2 = Encoding.ASCII.GetBytes("UPV2");
port.Write(upv2, 0, upv2.Length);
if (_protocolVersion < 2) _protocolVersion = 2;
buffer = "";
continue;
}
// Unsupported in debug mode
if (buffer == "UNSP")
{
log?.Invoke("Command not supported while Unirom is in debug mode.");
return false;
}
// Got the expected response
if (buffer == expected)
return true;
}
else
{
Thread.Sleep(1);
}
}
return false;
}
// ═══════════════════════════════════════════════════════════════
// Chunked data transfer with per-chunk checksum verification
// ═══════════════════════════════════════════════════════════════
private static bool WriteChunked(SerialPort port, byte[] data, bool skipFirstSector, Action<string> log)
{
int start = skipFirstSector ? CHUNK_SIZE : 0;
int totalDataBytes = data.Length - start;
int numChunks = (totalDataBytes + CHUNK_SIZE - 1) / CHUNK_SIZE;
int chunkIndex = 0;
for (int offset = start; offset < data.Length; )
{
// Determine chunk size (last chunk may be smaller)
int thisChunk = Math.Min(CHUNK_SIZE, data.Length - offset);
// Calculate per-chunk checksum (simple byte sum for V2, also works for V1)
ulong chunkChecksum = 0;
for (int j = 0; j < thisChunk; j++)
chunkChecksum += data[offset + j];
// Send the chunk
port.Write(data, offset, thisChunk);
// Wait for bytes to drain
while (port.BytesToWrite > 0)
Thread.Sleep(0);
chunkIndex++;
// Progress report every 10 chunks or on last chunk
if (chunkIndex % 10 == 0 || offset + thisChunk >= data.Length)
{
int sent = offset + thisChunk - start;
int pct = totalDataBytes > 0 ? sent * 100 / totalDataBytes : 100;
log?.Invoke($"Upload: {pct}% ({sent}/{totalDataBytes})");
}
// Protocol V2/V3: per-chunk checksum verification
if (_protocolVersion >= 2)
{
if (!HandleChunkAck(port, chunkChecksum, data, offset, thisChunk, log, out bool retry))
{
return false;
}
if (retry)
continue; // Don't advance offset — resend this chunk
}
offset += thisChunk;
}
return true;
}
/// <summary>
/// Handles the per-chunk CHEK/MORE/ERR! exchange for protocol V2+.
/// </summary>
private static bool HandleChunkAck(SerialPort port, ulong chunkChecksum, byte[] data, int offset, int chunkSize, Action<string> log, out bool retry)
{
retry = false;
// Wait for "CHEK" request from Unirom
string cmdBuffer = "";
DateTime deadline = DateTime.Now.AddMilliseconds(SERIAL_TIMEOUT_MS);
while (DateTime.Now < deadline)
{
if (port.BytesToRead > 0)
{
cmdBuffer += (char)port.ReadByte();
if (cmdBuffer.Length > 4)
cmdBuffer = cmdBuffer.Substring(cmdBuffer.Length - 4);
if (cmdBuffer == "CHEK")
break;
}
else
{
Thread.Sleep(1);
}
}
if (cmdBuffer != "CHEK")
{
log?.Invoke("Timeout waiting for CHEK from Unirom");
return false;
}
// Send the chunk checksum (4 bytes, little-endian)
port.Write(BitConverter.GetBytes((uint)chunkChecksum), 0, 4);
Thread.Sleep(1);
// Wait for MORE (ok) or ERR! (resend)
cmdBuffer = "";
deadline = DateTime.Now.AddMilliseconds(SERIAL_TIMEOUT_MS);
while (DateTime.Now < deadline)
{
if (port.BytesToRead > 0)
{
cmdBuffer += (char)port.ReadByte();
if (cmdBuffer.Length > 4)
cmdBuffer = cmdBuffer.Substring(cmdBuffer.Length - 4);
if (cmdBuffer == "MORE")
return true;
if (cmdBuffer == "ERR!")
{
log?.Invoke("Checksum error — retrying chunk...");
retry = true;
return true;
}
}
else
{
Thread.Sleep(1);
}
}
log?.Invoke("Timeout waiting for MORE/ERR! from Unirom");
return false;
}
// ═══════════════════════════════════════════════════════════════
// Checksum calculation
// ═══════════════════════════════════════════════════════════════
private static uint CalculateChecksum(byte[] data, bool skipFirstSector)
{
int start = skipFirstSector ? HEADER_SIZE : 0;
if (_protocolVersion == 3)
{
// DJB2 hash
uint hash = 5381;
for (int i = start; i < data.Length; i++)
hash = ((hash << 5) + hash) ^ data[i];
return hash;
}
else
{
// Simple byte sum
uint sum = 0;
for (int i = start; i < data.Length; i++)
sum += data[i];
return sum;
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e39963a5097ad6a48952a0a9d04d1563

View File

@@ -0,0 +1,39 @@
using UnityEditor;
namespace SplashEdit.EditorCode
{
/// <summary>
/// Automatically opens the SplashEdit Control Panel on the first editor
/// session if the MIPS toolchain has not been installed yet.
/// </summary>
[InitializeOnLoad]
public static class DependencyCheckInitializer
{
private const string SessionKey = "SplashEditOpenedThisSession";
static DependencyCheckInitializer()
{
EditorApplication.update += OpenControlPanelOnStart;
}
private static void OpenControlPanelOnStart()
{
EditorApplication.update -= OpenControlPanelOnStart;
if (SessionState.GetBool(SessionKey, false))
return;
SessionState.SetBool(SessionKey, true);
// Only auto-open the Control Panel when the toolchain is missing
bool toolchainReady = ToolchainChecker.IsToolAvailable("mips") ||
ToolchainChecker.IsToolAvailable("mipsel-none-elf-gcc") ||
ToolchainChecker.IsToolAvailable("mipsel-linux-gnu-gcc");
if (!toolchainReady)
{
SplashControlPanel.ShowWindow();
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c7043b9e1acbfbe40b9bd9be80e764e5

8
Editor/Inspectors.meta Normal file
View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 0279126b700b37d4485c1f4f1ae44e54
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,136 @@
using UnityEngine;
using UnityEditor;
using SplashEdit.RuntimeCode;
namespace SplashEdit.EditorCode
{
/// <summary>
/// Custom inspector for PSXInteractable component.
/// </summary>
[CustomEditor(typeof(PSXInteractable))]
public class PSXInteractableEditor : UnityEditor.Editor
{
private bool _interactionFoldout = true;
private bool _advancedFoldout = false;
private SerializedProperty _interactionRadius;
private SerializedProperty _interactButton;
private SerializedProperty _isRepeatable;
private SerializedProperty _cooldownFrames;
private SerializedProperty _showPrompt;
private SerializedProperty _requireLineOfSight;
private SerializedProperty _interactionOffset;
private static readonly string[] ButtonNames =
{
"Select", "L3", "R3", "Start", "Up", "Right", "Down", "Left",
"L2", "R2", "L1", "R1", "Triangle", "Circle", "Cross", "Square"
};
private void OnEnable()
{
_interactionRadius = serializedObject.FindProperty("interactionRadius");
_interactButton = serializedObject.FindProperty("interactButton");
_isRepeatable = serializedObject.FindProperty("isRepeatable");
_cooldownFrames = serializedObject.FindProperty("cooldownFrames");
_showPrompt = serializedObject.FindProperty("showPrompt");
_requireLineOfSight = serializedObject.FindProperty("requireLineOfSight");
_interactionOffset = serializedObject.FindProperty("interactionOffset");
}
public override void OnInspectorGUI()
{
serializedObject.Update();
DrawHeader();
EditorGUILayout.Space(5);
_interactionFoldout = DrawFoldoutSection("Interaction Settings", _interactionFoldout, () =>
{
EditorGUILayout.PropertyField(_interactionRadius);
// Button selector with visual
EditorGUILayout.BeginHorizontal();
EditorGUILayout.PrefixLabel("Interact Button");
_interactButton.intValue = EditorGUILayout.Popup(_interactButton.intValue, ButtonNames);
EditorGUILayout.EndHorizontal();
EditorGUILayout.PropertyField(_isRepeatable);
if (_isRepeatable.boolValue)
{
EditorGUI.indentLevel++;
EditorGUILayout.PropertyField(_cooldownFrames, new GUIContent("Cooldown (frames)"));
// Show cooldown in seconds
float seconds = _cooldownFrames.intValue / 60f;
EditorGUILayout.LabelField($"≈ {seconds:F2} seconds at 60fps", EditorStyles.miniLabel);
EditorGUI.indentLevel--;
}
EditorGUILayout.PropertyField(_showPrompt);
});
_advancedFoldout = DrawFoldoutSection("Advanced", _advancedFoldout, () =>
{
EditorGUILayout.PropertyField(_requireLineOfSight);
EditorGUILayout.PropertyField(_interactionOffset);
});
DrawLuaEventsInfo(new[] { "onInteract" });
serializedObject.ApplyModifiedProperties();
}
private void DrawHeader()
{
EditorGUILayout.BeginHorizontal(EditorStyles.helpBox);
GUILayout.Label(EditorGUIUtility.IconContent("d_Selectable Icon"), GUILayout.Width(30), GUILayout.Height(30));
EditorGUILayout.BeginVertical();
GUILayout.Label("PSX Interactable", EditorStyles.boldLabel);
GUILayout.Label("Player interaction trigger for PS1", EditorStyles.miniLabel);
EditorGUILayout.EndVertical();
EditorGUILayout.EndHorizontal();
}
private bool DrawFoldoutSection(string title, bool isExpanded, System.Action drawContent)
{
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
isExpanded = EditorGUILayout.Foldout(isExpanded, title, true, EditorStyles.foldoutHeader);
if (isExpanded)
{
EditorGUI.indentLevel++;
drawContent?.Invoke();
EditorGUI.indentLevel--;
}
EditorGUILayout.EndVertical();
EditorGUILayout.Space(3);
return isExpanded;
}
private void DrawLuaEventsInfo(string[] events)
{
EditorGUILayout.Space(5);
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
GUILayout.Label("Lua Events", EditorStyles.boldLabel);
EditorGUILayout.BeginHorizontal();
foreach (var evt in events)
{
GUILayout.Label($"• {evt}", EditorStyles.miniLabel);
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.EndVertical();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7bd9caaf5a0cb90409cf0acdf17d8d89

View File

@@ -1,15 +1,43 @@
using Splashedit.RuntimeCode; using SplashEdit.RuntimeCode;
using UnityEditor; using UnityEditor;
using UnityEngine; using UnityEngine;
[CustomEditor(typeof(LuaFile))] namespace SplashEdit.EditorCode
public class LuaScriptAssetEditor : Editor
{ {
private TextAsset asset; /// <summary>
/// Custom inspector for <see cref="LuaFile"/> assets that displays the
public override void OnInspectorGUI() /// embedded Lua source code in a read-only text area with an option to
/// open the source file in an external editor.
/// </summary>
[CustomEditor(typeof(LuaFile))]
public class LuaScriptAssetEditor : Editor
{ {
LuaFile luaScriptAsset = (LuaFile)target; private Vector2 _scrollPosition;
EditorGUILayout.TextArea(luaScriptAsset.LuaScript);
public override void OnInspectorGUI()
{
LuaFile luaScriptAsset = (LuaFile)target;
// Open in external editor button
string assetPath = AssetDatabase.GetAssetPath(target);
if (!string.IsNullOrEmpty(assetPath))
{
if (GUILayout.Button("Open in External Editor"))
{
// Opens the .lua source file in the OS-configured editor
UnityEditorInternal.InternalEditorUtility.OpenFileAtLineExternal(assetPath, 1);
}
EditorGUILayout.Space(4);
}
// Read-only source view
EditorGUILayout.LabelField("Lua Source", EditorStyles.boldLabel);
_scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition,
GUILayout.MaxHeight(400));
EditorGUI.BeginDisabledGroup(true);
EditorGUILayout.TextArea(luaScriptAsset.LuaScript, GUILayout.ExpandHeight(true));
EditorGUI.EndDisabledGroup();
EditorGUILayout.EndScrollView();
}
} }
} }

View File

@@ -1,2 +1,2 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 32c0501d523345500be12e6e4214ec9d guid: 66e212c64ebd0a34f9c23febe3e8545d

View File

@@ -2,11 +2,11 @@ using UnityEngine;
using System.IO; using System.IO;
using UnityEditor; using UnityEditor;
using UnityEditor.AssetImporters; using UnityEditor.AssetImporters;
using Splashedit.RuntimeCode; using SplashEdit.RuntimeCode;
namespace SplashEdit.EditorCode namespace SplashEdit.EditorCode
{ {
[ScriptedImporter(1, "lua")] [ScriptedImporter(2, "lua")]
class LuaImporter : ScriptedImporter class LuaImporter : ScriptedImporter
{ {
public override void OnImportAsset(AssetImportContext ctx) public override void OnImportAsset(AssetImportContext ctx)
@@ -19,7 +19,7 @@ namespace SplashEdit.EditorCode
ctx.AddObjectToAsset("Text", text); ctx.AddObjectToAsset("Text", text);
ctx.AddObjectToAsset("Script", asset); ctx.AddObjectToAsset("Script", asset);
ctx.SetMainObject(text); ctx.SetMainObject(asset); // LuaFile is the main object, not TextAsset
} }
} }
} }

View File

@@ -1,2 +1,2 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: d364a1392e3bccd77aca824ac471f89c guid: 74e983e6cf3376944af7b469023d6e4d

80
Editor/PSXMenuItems.cs Normal file
View File

@@ -0,0 +1,80 @@
using UnityEditor;
using UnityEngine;
using SplashEdit.RuntimeCode;
using System.Linq;
namespace SplashEdit.EditorCode
{
/// <summary>
/// Minimal menu items — everything goes through the unified Control Panel.
/// Only keeps: Control Panel shortcut + GameObject creation helpers.
/// </summary>
public static class PSXMenuItems
{
private const string MENU_ROOT = "PlayStation 1/";
// ───── Main Entry Point ─────
[MenuItem(MENU_ROOT + "SplashEdit Control Panel %#p", false, 0)]
public static void OpenControlPanel()
{
SplashControlPanel.ShowWindow();
}
// ───── GameObject Menu ─────
[MenuItem("GameObject/PlayStation 1/Scene Exporter", false, 10)]
public static void CreateSceneExporter(MenuCommand menuCommand)
{
var existing = Object.FindObjectOfType<PSXSceneExporter>();
if (existing != null)
{
EditorUtility.DisplayDialog(
"Scene Exporter Exists",
"A PSXSceneExporter already exists in this scene.\n\n" +
"Only one exporter is needed per scene.",
"OK");
Selection.activeGameObject = existing.gameObject;
return;
}
var go = new GameObject("PSXSceneExporter");
go.AddComponent<PSXSceneExporter>();
GameObjectUtility.SetParentAndAlign(go, menuCommand.context as GameObject);
Undo.RegisterCreatedObjectUndo(go, "Create PSX Scene Exporter");
Selection.activeGameObject = go;
}
[MenuItem("GameObject/PlayStation 1/Exportable Object", false, 12)]
public static void CreateExportableObject(MenuCommand menuCommand)
{
var go = new GameObject("PSXObject");
go.AddComponent<PSXObjectExporter>();
GameObjectUtility.SetParentAndAlign(go, menuCommand.context as GameObject);
Undo.RegisterCreatedObjectUndo(go, "Create PSX Object");
Selection.activeGameObject = go;
}
// ───── Context Menu ─────
[MenuItem("CONTEXT/MeshFilter/Add PSX Object Exporter")]
public static void AddPSXObjectExporterFromMesh(MenuCommand command)
{
var meshFilter = command.context as MeshFilter;
if (meshFilter != null && meshFilter.GetComponent<PSXObjectExporter>() == null)
{
Undo.AddComponent<PSXObjectExporter>(meshFilter.gameObject);
}
}
[MenuItem("CONTEXT/MeshRenderer/Add PSX Object Exporter")]
public static void AddPSXObjectExporterFromRenderer(MenuCommand command)
{
var renderer = command.context as MeshRenderer;
if (renderer != null && renderer.GetComponent<PSXObjectExporter>() == null)
{
Undo.AddComponent<PSXObjectExporter>(renderer.gameObject);
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 174ee99c9e9aafd4ea9002fc3548f53d

View File

@@ -1,31 +0,0 @@
using UnityEngine;
using UnityEditor;
using SplashEdit.RuntimeCode;
using System.Linq;
namespace SplashEdit.EditorCode
{
[CustomEditor(typeof(PSXNavMesh))]
public class PSXNavMeshEditor : Editor
{
public override void OnInspectorGUI()
{
DrawDefaultInspector();
PSXNavMesh comp = (PSXNavMesh)target;
if (GUILayout.Button("Create preview"))
{
PSXSceneExporter exporter = FindObjectsByType<PSXSceneExporter>(FindObjectsSortMode.None).FirstOrDefault();
if(exporter != null)
{
comp.CreateNavmesh(exporter.GTEScaling);
}
else
{
Debug.LogError("No PSXSceneExporter found in the scene. We can't pull the GTE scaling from the exporter.");
}
}
}
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 9d3bd83aac4c3ce9ab1698a6a2bc735d

View File

@@ -0,0 +1,401 @@
using UnityEngine;
using UnityEditor;
using SplashEdit.RuntimeCode;
using System.Collections.Generic;
namespace SplashEdit.EditorCode
{
/// <summary>
/// Editor window for PS1 navigation mesh generation.
/// Uses DotRecast (C# Recast) to voxelize scene geometry and build
/// convex navigation regions for the PS1 runtime.
/// All nav settings live on the PSXPlayer component so the editor
/// preview and the scene export always use the same values.
/// </summary>
public class PSXNavRegionEditor : EditorWindow
{
private PSXNavRegionBuilder _builder;
private bool _previewRegions = true;
private bool _previewPortals = true;
private bool _previewLabels = true;
private int _selectedRegion = -1;
private bool _showAdvanced = false;
[MenuItem("PSX/Nav Region Builder")]
public static void ShowWindow()
{
GetWindow<PSXNavRegionEditor>("Nav Region Builder");
}
private void OnEnable()
{
SceneView.duringSceneGui += OnSceneGUI;
}
private void OnDisable()
{
SceneView.duringSceneGui -= OnSceneGUI;
}
private void OnGUI()
{
EditorGUILayout.Space(5);
GUILayout.Label("PSX Nav Region Builder", EditorStyles.boldLabel);
EditorGUILayout.Space(5);
var players = FindObjectsByType<PSXPlayer>(FindObjectsSortMode.None);
if (players.Length == 0)
{
EditorGUILayout.HelpBox(
"No PSXPlayer in scene. Add a PSXPlayer component to configure navigation settings.",
MessageType.Warning);
return;
}
var player = players[0];
var so = new SerializedObject(player);
so.Update();
// Info
using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox))
{
EditorGUILayout.HelpBox(
"Uses DotRecast (Recast voxelization) to build PS1 nav regions.\n" +
"Settings are on the PSXPlayer component so editor preview\n" +
"and scene export always match.\n" +
"1. Configure settings below (saved on PSXPlayer)\n" +
"2. Click 'Build Nav Regions' to preview\n" +
"3. Results export automatically with the scene",
MessageType.Info);
}
EditorGUILayout.Space(5);
// Agent settings (from PSXPlayer serialized fields)
using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox))
{
GUILayout.Label("Agent Settings (PSXPlayer)", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(so.FindProperty("playerHeight"),
new GUIContent("Agent Height", "Camera eye height above feet"));
EditorGUILayout.PropertyField(so.FindProperty("playerRadius"),
new GUIContent("Agent Radius", "Collision radius for wall sliding"));
EditorGUILayout.PropertyField(so.FindProperty("maxStepHeight"),
new GUIContent("Max Step Height", "Maximum height the agent can step up"));
EditorGUILayout.PropertyField(so.FindProperty("walkableSlopeAngle"),
new GUIContent("Max Slope", "Maximum walkable slope angle in degrees"));
}
EditorGUILayout.Space(5);
// Advanced settings
_showAdvanced = EditorGUILayout.Foldout(_showAdvanced, "Advanced Settings");
if (_showAdvanced)
{
using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox))
{
EditorGUILayout.PropertyField(so.FindProperty("navCellSize"),
new GUIContent("Cell Size", "Voxel size in XZ plane. Smaller = more accurate but slower."));
EditorGUILayout.PropertyField(so.FindProperty("navCellHeight"),
new GUIContent("Cell Height", "Voxel height. Smaller = more accurate vertical resolution."));
EditorGUILayout.Space(3);
float cs = player.NavCellSize;
float ch = player.NavCellHeight;
int walkH = (int)System.Math.Ceiling(player.PlayerHeight / ch);
int walkR = (int)System.Math.Ceiling(player.PlayerRadius / cs);
int walkC = (int)System.Math.Floor(player.MaxStepHeight / ch);
EditorGUILayout.LabelField("Voxel walkable height", $"{walkH} cells");
EditorGUILayout.LabelField("Voxel walkable radius", $"{walkR} cells");
EditorGUILayout.LabelField("Voxel walkable climb", $"{walkC} cells ({walkC * ch:F3} units)");
}
}
so.ApplyModifiedProperties();
EditorGUILayout.Space(5);
// Build button
using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox))
{
GUILayout.Label("Generation", EditorStyles.boldLabel);
GUI.backgroundColor = new Color(0.4f, 0.8f, 0.4f);
if (GUILayout.Button("Build Nav Regions", GUILayout.Height(35)))
{
BuildNavRegions(player);
}
GUI.backgroundColor = Color.white;
if (_builder != null && _builder.RegionCount > 0)
{
EditorGUILayout.Space(3);
if (GUILayout.Button("Clear Regions"))
{
_builder = null;
_selectedRegion = -1;
SceneView.RepaintAll();
}
}
}
EditorGUILayout.Space(5);
// Visualization
using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox))
{
GUILayout.Label("Visualization", EditorStyles.boldLabel);
_previewRegions = EditorGUILayout.Toggle("Show Regions", _previewRegions);
_previewPortals = EditorGUILayout.Toggle("Show Portals", _previewPortals);
_previewLabels = EditorGUILayout.Toggle("Show Labels", _previewLabels);
}
EditorGUILayout.Space(5);
// Statistics
if (_builder != null && _builder.RegionCount > 0)
{
using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox))
{
GUILayout.Label("Statistics", EditorStyles.boldLabel);
EditorGUILayout.LabelField("Regions", _builder.RegionCount.ToString());
EditorGUILayout.LabelField("Portals", _builder.PortalCount.ToString());
var rooms = new HashSet<byte>();
for (int i = 0; i < _builder.RegionCount; i++)
rooms.Add(_builder.Regions[i].roomIndex);
EditorGUILayout.LabelField("Rooms", rooms.Count.ToString());
int exportSize = _builder.GetBinarySize();
EditorGUILayout.LabelField("Export Size",
$"{exportSize:N0} bytes ({exportSize / 1024f:F1} KB)");
int flat = 0, ramp = 0, stairs = 0;
for (int i = 0; i < _builder.RegionCount; i++)
{
switch (_builder.Regions[i].surfaceType)
{
case NavSurfaceType.Flat: flat++; break;
case NavSurfaceType.Ramp: ramp++; break;
case NavSurfaceType.Stairs: stairs++; break;
}
}
EditorGUILayout.LabelField("Types",
$"{flat} flat, {ramp} ramp, {stairs} stairs");
if (_selectedRegion >= 0 && _selectedRegion < _builder.RegionCount)
{
EditorGUILayout.Space(3);
GUILayout.Label($"Selected Region #{_selectedRegion}",
EditorStyles.miniLabel);
var region = _builder.Regions[_selectedRegion];
EditorGUILayout.LabelField(" Vertices",
region.vertsXZ.Count.ToString());
EditorGUILayout.LabelField(" Portals",
region.portalCount.ToString());
EditorGUILayout.LabelField(" Surface",
region.surfaceType.ToString());
EditorGUILayout.LabelField(" Room",
region.roomIndex.ToString());
EditorGUILayout.LabelField(" Floor Y",
$"{region.planeD:F2} (A={region.planeA:F3}, B={region.planeB:F3})");
}
}
ValidateRegions();
}
else
{
EditorGUILayout.HelpBox(
"No nav regions built. Click 'Build Nav Regions' to generate.",
MessageType.Warning);
}
}
// ====================================================================
// Build
// ====================================================================
private void BuildNavRegions(PSXPlayer player)
{
EditorUtility.DisplayProgressBar("Nav Region Builder", "Building nav regions with DotRecast...", 0.3f);
_builder = new PSXNavRegionBuilder();
_builder.AgentHeight = player.PlayerHeight;
_builder.AgentRadius = player.PlayerRadius;
_builder.MaxStepHeight = player.MaxStepHeight;
_builder.WalkableSlopeAngle = player.WalkableSlopeAngle;
_builder.CellSize = player.NavCellSize;
_builder.CellHeight = player.NavCellHeight;
Vector3 playerSpawn = player.transform.position;
player.FindNavmesh();
playerSpawn = player.CamPoint;
PSXObjectExporter[] exporters =
FindObjectsByType<PSXObjectExporter>(FindObjectsSortMode.None);
_builder.Build(exporters, playerSpawn);
_selectedRegion = -1;
EditorUtility.ClearProgressBar();
SceneView.RepaintAll();
}
// ====================================================================
// Validation
// ====================================================================
private void ValidateRegions()
{
if (_builder == null) return;
List<string> warnings = new List<string>();
for (int i = 0; i < _builder.RegionCount; i++)
{
var region = _builder.Regions[i];
if (region.vertsXZ.Count < 3)
warnings.Add($"Region {i}: degenerate ({region.vertsXZ.Count} verts)");
if (region.portalCount == 0 && _builder.RegionCount > 1)
warnings.Add($"Region {i}: isolated (no portals)");
if (region.vertsXZ.Count > 8)
warnings.Add($"Region {i}: too many verts ({region.vertsXZ.Count} > 8)");
}
int exportSize = _builder.GetBinarySize();
if (exportSize > 8192)
warnings.Add($"Export size {exportSize} bytes is large for PS1 (> 8KB)");
if (warnings.Count > 0)
{
EditorGUILayout.Space(5);
using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox))
{
GUILayout.Label("Warnings", EditorStyles.boldLabel);
foreach (string w in warnings)
EditorGUILayout.LabelField(w, EditorStyles.miniLabel);
}
}
}
// ====================================================================
// Scene view drawing
// ====================================================================
private static readonly Color[] RoomColors = new Color[]
{
new Color(0.2f, 0.8f, 0.2f),
new Color(0.2f, 0.6f, 0.9f),
new Color(0.9f, 0.7f, 0.1f),
new Color(0.8f, 0.2f, 0.8f),
new Color(0.1f, 0.9f, 0.9f),
new Color(0.9f, 0.5f, 0.2f),
new Color(0.5f, 0.9f, 0.3f),
new Color(0.9f, 0.3f, 0.5f),
new Color(0.4f, 0.4f, 0.9f),
new Color(0.7f, 0.9f, 0.7f),
new Color(0.9f, 0.9f, 0.4f),
new Color(0.6f, 0.3f, 0.6f),
new Color(0.3f, 0.7f, 0.7f),
new Color(0.8f, 0.6f, 0.4f),
new Color(0.4f, 0.8f, 0.6f),
new Color(0.7f, 0.4f, 0.4f),
};
private void OnSceneGUI(SceneView sceneView)
{
if (_builder == null || _builder.RegionCount == 0) return;
var regions = _builder.Regions;
if (_previewRegions)
{
for (int i = 0; i < regions.Count; i++)
{
var region = regions[i];
bool selected = (i == _selectedRegion);
Color baseColor = RoomColors[region.roomIndex % RoomColors.Length];
float fillAlpha = selected ? 0.4f : 0.15f;
if (region.vertsXZ.Count >= 3)
{
Vector3[] worldVerts = new Vector3[region.vertsXZ.Count];
for (int v = 0; v < region.vertsXZ.Count; v++)
{
float y = region.planeA * region.vertsXZ[v].x +
region.planeB * region.vertsXZ[v].y +
region.planeD;
worldVerts[v] = new Vector3(
region.vertsXZ[v].x, y + 0.05f, region.vertsXZ[v].y);
}
Handles.color = selected
? Color.white
: new Color(baseColor.r, baseColor.g, baseColor.b, 0.8f);
for (int v = 0; v < worldVerts.Length; v++)
Handles.DrawLine(worldVerts[v],
worldVerts[(v + 1) % worldVerts.Length]);
Handles.color = new Color(baseColor.r, baseColor.g, baseColor.b,
fillAlpha);
for (int v = 1; v < worldVerts.Length - 1; v++)
Handles.DrawAAConvexPolygon(
worldVerts[0], worldVerts[v], worldVerts[v + 1]);
if (_previewLabels)
{
Vector3 center = Vector3.zero;
foreach (var wv in worldVerts) center += wv;
center /= worldVerts.Length;
string label = $"R{i}";
if (region.roomIndex != 0xFF)
label += $"\nRm{region.roomIndex}";
Handles.Label(center, label, EditorStyles.whiteBoldLabel);
if (Handles.Button(center, Quaternion.identity,
0.2f, 0.3f, Handles.SphereHandleCap))
{
_selectedRegion = i;
Repaint();
}
}
}
}
}
if (_previewPortals && _builder.Portals != null)
{
for (int i = 0; i < regions.Count; i++)
{
var region = regions[i];
int pStart = region.portalStart;
int pCount = region.portalCount;
for (int p = pStart;
p < pStart + pCount && p < _builder.Portals.Count; p++)
{
var portal = _builder.Portals[p];
float yA = region.planeA * portal.a.x +
region.planeB * portal.a.y + region.planeD;
float yB = region.planeA * portal.b.x +
region.planeB * portal.b.y + region.planeD;
Vector3 worldA = new Vector3(portal.a.x, yA + 0.08f, portal.a.y);
Vector3 worldB = new Vector3(portal.b.x, yB + 0.08f, portal.b.y);
if (Mathf.Abs(portal.heightDelta) <= 0.35f)
Handles.color = new Color(1f, 1f, 1f, 0.9f);
else
Handles.color = new Color(1f, 0.9f, 0.2f, 0.9f);
Handles.DrawLine(worldA, worldB, 3f);
}
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e6ea40b4c8e02314c9388c86b2920403

View File

@@ -0,0 +1,514 @@
using UnityEngine;
using UnityEditor;
using SplashEdit.RuntimeCode;
using System.Linq;
using System.Collections.Generic;
namespace SplashEdit.EditorCode
{
/// <summary>
/// Custom inspector for PSXObjectExporter with enhanced UX.
/// Shows mesh info, texture preview, collision visualization, and validation.
/// </summary>
[CustomEditor(typeof(PSXObjectExporter))]
[CanEditMultipleObjects]
public class PSXObjectExporterEditor : UnityEditor.Editor
{
// Serialized properties
private SerializedProperty isActiveProp;
private SerializedProperty bitDepthProp;
private SerializedProperty luaFileProp;
private SerializedProperty objectFlagsProp;
private SerializedProperty collisionTypeProp;
private SerializedProperty exportCollisionMeshProp;
private SerializedProperty customCollisionMeshProp;
private SerializedProperty collisionLayerProp;
private SerializedProperty previewNormalsProp;
private SerializedProperty normalPreviewLengthProp;
private SerializedProperty showCollisionBoundsProp;
private SerializedProperty textureProp;
// UI State
private bool showMeshInfo = true;
private bool showTextureInfo = true;
private bool showExportSettings = true;
private bool showCollisionSettings = true;
private bool showGizmoSettings = false;
private bool showValidation = true;
// Cached data
private MeshFilter meshFilter;
private MeshRenderer meshRenderer;
private int triangleCount;
private int vertexCount;
private Bounds meshBounds;
private List<string> validationErrors = new List<string>();
private List<string> validationWarnings = new List<string>();
// Styles
private GUIStyle headerStyle;
private GUIStyle errorStyle;
private GUIStyle warningStyle;
// Validation
private bool _validationDirty = true;
private void OnEnable()
{
// Get serialized properties
isActiveProp = serializedObject.FindProperty("isActive");
bitDepthProp = serializedObject.FindProperty("bitDepth");
luaFileProp = serializedObject.FindProperty("luaFile");
objectFlagsProp = serializedObject.FindProperty("objectFlags");
collisionTypeProp = serializedObject.FindProperty("collisionType");
exportCollisionMeshProp = serializedObject.FindProperty("exportCollisionMesh");
customCollisionMeshProp = serializedObject.FindProperty("customCollisionMesh");
collisionLayerProp = serializedObject.FindProperty("collisionLayer");
previewNormalsProp = serializedObject.FindProperty("previewNormals");
normalPreviewLengthProp = serializedObject.FindProperty("normalPreviewLength");
showCollisionBoundsProp = serializedObject.FindProperty("showCollisionBounds");
textureProp = serializedObject.FindProperty("texture");
// Cache mesh info
CacheMeshInfo();
// Defer validation to first inspector draw
_validationDirty = true;
}
private void CacheMeshInfo()
{
var exporter = target as PSXObjectExporter;
if (exporter == null) return;
meshFilter = exporter.GetComponent<MeshFilter>();
meshRenderer = exporter.GetComponent<MeshRenderer>();
if (meshFilter != null && meshFilter.sharedMesh != null)
{
var mesh = meshFilter.sharedMesh;
triangleCount = mesh.triangles.Length / 3;
vertexCount = mesh.vertexCount;
meshBounds = mesh.bounds;
}
}
private void RunValidation()
{
validationErrors.Clear();
validationWarnings.Clear();
var exporter = target as PSXObjectExporter;
if (exporter == null) return;
// Check mesh
if (meshFilter == null || meshFilter.sharedMesh == null)
{
validationErrors.Add("No mesh assigned to MeshFilter");
}
else
{
if (triangleCount > 100)
{
validationWarnings.Add($"High triangle count ({triangleCount}). PS1 recommended: <100 per object");
}
// Check vertex bounds
var mesh = meshFilter.sharedMesh;
var verts = mesh.vertices;
bool hasOutOfBounds = false;
foreach (var v in verts)
{
var world = exporter.transform.TransformPoint(v);
float scaled = Mathf.Max(Mathf.Abs(world.x), Mathf.Abs(world.y), Mathf.Abs(world.z)) * 4096f;
if (scaled > 32767f)
{
hasOutOfBounds = true;
break;
}
}
if (hasOutOfBounds)
{
validationErrors.Add("Vertices exceed PS1 coordinate limits (±8 units from origin)");
}
}
// Check renderer
if (meshRenderer == null)
{
validationWarnings.Add("No MeshRenderer - object will not be visible");
}
else if (meshRenderer.sharedMaterial == null)
{
validationWarnings.Add("No material assigned - will use default colors");
}
}
public override void OnInspectorGUI()
{
serializedObject.Update();
// Run deferred validation
if (_validationDirty)
{
RunValidation();
_validationDirty = false;
}
InitStyles();
// Active toggle at top
EditorGUILayout.PropertyField(isActiveProp, new GUIContent("Export This Object"));
if (!isActiveProp.boolValue)
{
EditorGUILayout.HelpBox("This object will be skipped during export.", MessageType.Info);
serializedObject.ApplyModifiedProperties();
return;
}
EditorGUILayout.Space(5);
// Mesh Info Section
DrawMeshInfoSection();
// Texture Section
DrawTextureSection();
// Export Settings Section
DrawExportSettingsSection();
// Collision Settings Section
DrawCollisionSettingsSection();
// Gizmo Settings Section
DrawGizmoSettingsSection();
// Validation Section
DrawValidationSection();
// Action Buttons
DrawActionButtons();
if (serializedObject.ApplyModifiedProperties())
{
_validationDirty = true;
}
}
private void InitStyles()
{
if (headerStyle == null)
{
headerStyle = new GUIStyle(EditorStyles.foldoutHeader);
}
if (errorStyle == null)
{
errorStyle = new GUIStyle(EditorStyles.label);
errorStyle.normal.textColor = Color.red;
}
if (warningStyle == null)
{
warningStyle = new GUIStyle(EditorStyles.label);
warningStyle.normal.textColor = new Color(1f, 0.7f, 0f);
}
}
private void DrawMeshInfoSection()
{
showMeshInfo = EditorGUILayout.BeginFoldoutHeaderGroup(showMeshInfo, "Mesh Information");
if (showMeshInfo)
{
EditorGUI.indentLevel++;
if (meshFilter != null && meshFilter.sharedMesh != null)
{
EditorGUILayout.LabelField("Mesh", meshFilter.sharedMesh.name);
EditorGUILayout.LabelField("Triangles", triangleCount.ToString());
EditorGUILayout.LabelField("Vertices", vertexCount.ToString());
EditorGUILayout.LabelField("Bounds Size", meshBounds.size.ToString("F2"));
// Triangle budget bar
float budgetPercent = triangleCount / 100f;
Rect rect = EditorGUILayout.GetControlRect(false, 20);
EditorGUI.ProgressBar(rect, Mathf.Clamp01(budgetPercent), $"Triangle Budget: {triangleCount}/100");
}
else
{
EditorGUILayout.HelpBox("No mesh assigned", MessageType.Warning);
}
EditorGUI.indentLevel--;
}
EditorGUILayout.EndFoldoutHeaderGroup();
}
private void DrawTextureSection()
{
showTextureInfo = EditorGUILayout.BeginFoldoutHeaderGroup(showTextureInfo, "Texture Settings");
if (showTextureInfo)
{
EditorGUI.indentLevel++;
EditorGUILayout.PropertyField(textureProp, new GUIContent("Override Texture"));
EditorGUILayout.PropertyField(bitDepthProp, new GUIContent("Bit Depth"));
// Show texture preview if assigned
var tex = textureProp.objectReferenceValue as Texture2D;
if (tex != null)
{
EditorGUILayout.Space(5);
using (new EditorGUILayout.HorizontalScope())
{
GUILayout.FlexibleSpace();
Rect previewRect = GUILayoutUtility.GetRect(64, 64, GUILayout.Width(64));
EditorGUI.DrawPreviewTexture(previewRect, tex);
GUILayout.FlexibleSpace();
}
EditorGUILayout.LabelField($"Size: {tex.width}x{tex.height}");
// VRAM estimate
int bpp = bitDepthProp.enumValueIndex == 0 ? 4 : (bitDepthProp.enumValueIndex == 1 ? 8 : 16);
int vramBytes = (tex.width * tex.height * bpp) / 8;
EditorGUILayout.LabelField($"Est. VRAM: {vramBytes} bytes ({bpp}bpp)");
}
else if (meshRenderer != null && meshRenderer.sharedMaterial != null)
{
var matTex = meshRenderer.sharedMaterial.mainTexture;
if (matTex != null)
{
EditorGUILayout.HelpBox($"Using material texture: {matTex.name}", MessageType.Info);
}
}
EditorGUI.indentLevel--;
}
EditorGUILayout.EndFoldoutHeaderGroup();
}
private void DrawExportSettingsSection()
{
showExportSettings = EditorGUILayout.BeginFoldoutHeaderGroup(showExportSettings, "Export Settings");
if (showExportSettings)
{
EditorGUI.indentLevel++;
EditorGUILayout.PropertyField(objectFlagsProp, new GUIContent("Object Flags"));
EditorGUILayout.PropertyField(luaFileProp, new GUIContent("Lua Script"));
// Quick Lua file buttons
if (luaFileProp.objectReferenceValue != null)
{
using (new EditorGUILayout.HorizontalScope())
{
if (GUILayout.Button("Edit Lua", GUILayout.Width(80)))
{
AssetDatabase.OpenAsset(luaFileProp.objectReferenceValue);
}
if (GUILayout.Button("Clear", GUILayout.Width(60)))
{
luaFileProp.objectReferenceValue = null;
}
}
}
else
{
if (GUILayout.Button("Create New Lua Script"))
{
CreateNewLuaScript();
}
}
EditorGUI.indentLevel--;
}
EditorGUILayout.EndFoldoutHeaderGroup();
}
private void DrawCollisionSettingsSection()
{
showCollisionSettings = EditorGUILayout.BeginFoldoutHeaderGroup(showCollisionSettings, "Collision Settings");
if (showCollisionSettings)
{
EditorGUI.indentLevel++;
EditorGUILayout.PropertyField(collisionTypeProp, new GUIContent("Collision Type"));
var collType = (PSXCollisionType)collisionTypeProp.enumValueIndex;
if (collType != PSXCollisionType.None)
{
EditorGUILayout.PropertyField(exportCollisionMeshProp, new GUIContent("Export Collision Mesh"));
EditorGUILayout.PropertyField(customCollisionMeshProp, new GUIContent("Custom Collision Mesh"));
EditorGUILayout.PropertyField(collisionLayerProp, new GUIContent("Collision Layer"));
// Collision info
EditorGUILayout.Space(5);
string collisionInfo = collType switch
{
PSXCollisionType.Solid => "Solid: Blocks movement, fires onCollision",
PSXCollisionType.Trigger => "Trigger: Fires onTriggerEnter/Exit, doesn't block",
PSXCollisionType.Platform => "Platform: Solid from above only",
_ => ""
};
EditorGUILayout.HelpBox(collisionInfo, MessageType.Info);
}
EditorGUI.indentLevel--;
}
EditorGUILayout.EndFoldoutHeaderGroup();
}
private void DrawGizmoSettingsSection()
{
showGizmoSettings = EditorGUILayout.BeginFoldoutHeaderGroup(showGizmoSettings, "Gizmo Settings");
if (showGizmoSettings)
{
EditorGUI.indentLevel++;
EditorGUILayout.PropertyField(previewNormalsProp, new GUIContent("Preview Normals"));
if (previewNormalsProp.boolValue)
{
EditorGUILayout.PropertyField(normalPreviewLengthProp, new GUIContent("Normal Length"));
}
EditorGUILayout.PropertyField(showCollisionBoundsProp, new GUIContent("Show Collision Bounds"));
EditorGUI.indentLevel--;
}
EditorGUILayout.EndFoldoutHeaderGroup();
}
private void DrawValidationSection()
{
if (validationErrors.Count == 0 && validationWarnings.Count == 0)
return;
showValidation = EditorGUILayout.BeginFoldoutHeaderGroup(showValidation, "Validation");
if (showValidation)
{
foreach (var error in validationErrors)
{
EditorGUILayout.HelpBox(error, MessageType.Error);
}
foreach (var warning in validationWarnings)
{
EditorGUILayout.HelpBox(warning, MessageType.Warning);
}
if (GUILayout.Button("Refresh Validation"))
{
CacheMeshInfo();
RunValidation();
}
}
EditorGUILayout.EndFoldoutHeaderGroup();
}
private void DrawActionButtons()
{
EditorGUILayout.Space(10);
using (new EditorGUILayout.HorizontalScope())
{
if (GUILayout.Button("Select Scene Exporter"))
{
var exporter = FindObjectOfType<PSXSceneExporter>();
if (exporter != null)
{
Selection.activeGameObject = exporter.gameObject;
}
else
{
EditorUtility.DisplayDialog("Not Found", "No PSXSceneExporter in scene.", "OK");
}
}
if (GUILayout.Button("Open Scene Validator"))
{
PSXSceneValidatorWindow.ShowWindow();
}
}
}
private void CreateNewLuaScript()
{
var exporter = target as PSXObjectExporter;
string defaultName = exporter.gameObject.name.ToLower().Replace(" ", "_");
string path = EditorUtility.SaveFilePanelInProject(
"Create Lua Script",
defaultName + ".lua",
"lua",
"Create a new Lua script for this object");
if (!string.IsNullOrEmpty(path))
{
string template = $@"-- Lua script for {exporter.gameObject.name}
--
-- Available globals: Entity, Vec3, Input, Timer, Camera, Audio,
-- Debug, Math, Scene, Persist
--
-- Available events:
-- onCreate(self) — called once when the object is registered
-- onUpdate(self, dt) — called every frame (dt = delta frames, usually 1)
-- onEnable(self) — called when the object becomes active
-- onDisable(self) — called when the object becomes inactive
-- onCollision(self, other) — called on collision with another object
-- onTriggerEnter(self, other)
-- onTriggerStay(self, other)
-- onTriggerExit(self, other)
-- onInteract(self) — called when the player interacts
-- onButtonPress(self, btn) — called on button press (btn = Input.CROSS etc.)
-- onButtonRelease(self, btn)
-- onDestroy(self) — called before the object is destroyed
--
-- Properties: self.position (Vec3), self.rotationY (pi-units), self.active (bool)
function onCreate(self)
-- Called once when this object is registered in the scene
end
function onUpdate(self, dt)
-- Called every frame. dt = number of elapsed frames (usually 1).
end
function onInteract(self)
-- Called when the player interacts with this object
end
";
System.IO.File.WriteAllText(path, template);
AssetDatabase.Refresh();
var luaFile = AssetDatabase.LoadAssetAtPath<LuaFile>(path);
if (luaFile != null)
{
luaFileProp.objectReferenceValue = luaFile;
serializedObject.ApplyModifiedProperties();
}
}
}
[MenuItem("CONTEXT/PSXObjectExporter/Copy Settings to Selected")]
private static void CopySettingsToSelected(MenuCommand command)
{
var source = command.context as PSXObjectExporter;
if (source == null) return;
foreach (var go in Selection.gameObjects)
{
var target = go.GetComponent<PSXObjectExporter>();
if (target != null && target != source)
{
Undo.RecordObject(target, "Copy PSX Settings");
// Copy via serialized object
EditorUtility.CopySerialized(source, target);
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d45032f12fc4b614783ad30927846e6c

View File

@@ -4,19 +4,140 @@ using SplashEdit.RuntimeCode;
namespace SplashEdit.EditorCode namespace SplashEdit.EditorCode
{ {
/// <summary>
/// Custom inspector for PSXSceneExporter.
/// When the component is selected and fog is enabled, activates a Unity scene-view
/// fog preview that approximates the PS1 linear fog distances.
///
/// Fog distance mapping:
/// fogFarSZ = 8000 / FogDensity (GTE SZ units)
/// fogNearSZ = fogFarSZ / 3
/// SZ is 20.12 fixed-point: SZ = (unityCoord / GTEScaling) * 4096
/// => unityDist = SZ * GTEScaling / 4096
/// => Unity fog near = (8000 / (FogDensity * 3)) * GTEScaling / 4096
/// => Unity fog far = (8000 / FogDensity) * GTEScaling / 4096
/// </summary>
[CustomEditor(typeof(PSXSceneExporter))] [CustomEditor(typeof(PSXSceneExporter))]
public class PSXSceneExporterEditor : Editor public class PSXSceneExporterEditor : UnityEditor.Editor
{ {
public override void OnInspectorGUI() // Saved RenderSettings state so we can restore it on deselect.
{ private bool _savedFog;
DrawDefaultInspector(); private Color _savedFogColor;
private FogMode _savedFogMode;
private float _savedFogStart;
private float _savedFogEnd;
PSXSceneExporter comp = (PSXSceneExporter)target; private bool _previewActive = false;
if (GUILayout.Button("Export"))
private void OnEnable()
{
SaveAndApplyFogPreview();
// Re-apply whenever the scene is repainted (handles inspector value changes).
EditorApplication.update += OnEditorUpdate;
}
private void OnDisable()
{
EditorApplication.update -= OnEditorUpdate;
RestoreFog();
}
private void OnEditorUpdate()
{
// Keep the preview in sync when the user tweaks values in the inspector.
if (_previewActive)
ApplyFogPreview();
}
private void SaveAndApplyFogPreview()
{
_savedFog = RenderSettings.fog;
_savedFogColor = RenderSettings.fogColor;
_savedFogMode = RenderSettings.fogMode;
_savedFogStart = RenderSettings.fogStartDistance;
_savedFogEnd = RenderSettings.fogEndDistance;
_previewActive = true;
ApplyFogPreview();
}
private void ApplyFogPreview()
{
var exporter = (PSXSceneExporter)target;
if (exporter == null) return;
if (!exporter.FogEnabled)
{ {
comp.Export(); // Fog disabled on the component - turn off the preview.
RenderSettings.fog = false;
return;
} }
float gteScale = exporter.GTEScaling;
int density = Mathf.Clamp(exporter.FogDensity, 1, 10);
// fogFarSZ in GTE SZ units (20.12 fp); convert to Unity world-space.
// SZ = (unityDist / GTEScaling) * 4096, so unityDist = SZ * GTEScaling / 4096
float fogFarSZ = 8000f / density;
float fogNearSZ = fogFarSZ / 3f;
float fogFarUnity = fogFarSZ * gteScale / 4096f;
float fogNearUnity = fogNearSZ * gteScale / 4096f;
RenderSettings.fog = true;
RenderSettings.fogColor = exporter.FogColor;
RenderSettings.fogMode = FogMode.Linear;
RenderSettings.fogStartDistance = fogNearUnity;
RenderSettings.fogEndDistance = fogFarUnity;
}
private void RestoreFog()
{
if (!_previewActive) return;
_previewActive = false;
RenderSettings.fog = _savedFog;
RenderSettings.fogColor = _savedFogColor;
RenderSettings.fogMode = _savedFogMode;
RenderSettings.fogStartDistance = _savedFogStart;
RenderSettings.fogEndDistance = _savedFogEnd;
}
public override void OnInspectorGUI()
{
serializedObject.Update();
DrawDefaultInspector();
// Show computed fog distances when fog is enabled, so the user
// can see exactly what range the preview represents.
var exporter = (PSXSceneExporter)target;
if (exporter.FogEnabled)
{
EditorGUILayout.Space(4);
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
GUILayout.Label("Fog Preview (active in Scene view)", EditorStyles.boldLabel);
float gteScale = exporter.GTEScaling;
int density = Mathf.Clamp(exporter.FogDensity, 1, 10);
float fogFarUnity = (8000f / density) * gteScale / 4096f;
float fogNearUnity = fogFarUnity / 3f;
EditorGUILayout.LabelField("Near distance", $"{fogNearUnity:F1} Unity units");
EditorGUILayout.LabelField("Far distance", $"{fogFarUnity:F1} Unity units");
EditorGUILayout.LabelField("(PS1 SZ range)", $"{8000f / (density * 3f):F0} - {8000f / density:F0} GTE units");
EditorGUILayout.EndVertical();
// Keep preview applied as values may have changed.
ApplyFogPreview();
}
else
{
// Make sure preview is off when fog is disabled.
RenderSettings.fog = false;
}
serializedObject.ApplyModifiedProperties();
} }
} }
} }

View File

@@ -1,2 +1,2 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: becf2eb607e7a60baaf3bebe4683d66f guid: 738efb5c0ed755b45991d2067957b997

View File

@@ -0,0 +1,496 @@
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEngine;
using SplashEdit.RuntimeCode;
namespace SplashEdit.EditorCode
{
/// <summary>
/// Scene Validator Window - Validates the current scene for PS1 compatibility.
/// Checks for common issues that would cause problems on real hardware.
/// </summary>
public class PSXSceneValidatorWindow : EditorWindow
{
private Vector2 scrollPosition;
private List<ValidationResult> validationResults = new List<ValidationResult>();
private bool hasValidated = false;
private int errorCount = 0;
private int warningCount = 0;
private int infoCount = 0;
// Filter toggles
private bool showErrors = true;
private bool showWarnings = true;
private bool showInfo = true;
// PS1 Limits
private const int MAX_RECOMMENDED_TRIS_PER_OBJECT = 100;
private const int MAX_RECOMMENDED_TOTAL_TRIS = 400;
private const int MAX_VERTEX_COORD = 32767; // signed 16-bit
private const int MIN_VERTEX_COORD = -32768;
private const int VRAM_WIDTH = 1024;
private const int VRAM_HEIGHT = 512;
private static readonly Vector2 MinSize = new Vector2(500, 400);
public static void ShowWindow()
{
var window = GetWindow<PSXSceneValidatorWindow>("Scene Validator");
window.minSize = MinSize;
}
private void OnEnable()
{
validationResults.Clear();
hasValidated = false;
}
private void OnGUI()
{
DrawHeader();
DrawFilters();
DrawResults();
DrawFooter();
}
private void DrawHeader()
{
EditorGUILayout.Space(5);
using (new EditorGUILayout.HorizontalScope())
{
GUILayout.Label("PS1 Scene Validator", EditorStyles.boldLabel);
GUILayout.FlexibleSpace();
if (GUILayout.Button("Validate Scene", GUILayout.Width(120)))
{
ValidateScene();
}
}
EditorGUILayout.Space(5);
// Summary bar
if (hasValidated)
{
using (new EditorGUILayout.HorizontalScope(EditorStyles.helpBox))
{
var errorStyle = new GUIStyle(EditorStyles.label);
errorStyle.normal.textColor = errorCount > 0 ? Color.red : Color.green;
GUILayout.Label($"✗ {errorCount} Errors", errorStyle);
var warnStyle = new GUIStyle(EditorStyles.label);
warnStyle.normal.textColor = warningCount > 0 ? new Color(1f, 0.7f, 0f) : Color.green;
GUILayout.Label($"⚠ {warningCount} Warnings", warnStyle);
var infoStyle = new GUIStyle(EditorStyles.label);
infoStyle.normal.textColor = Color.cyan;
GUILayout.Label($" {infoCount} Info", infoStyle);
GUILayout.FlexibleSpace();
}
}
EditorGUILayout.Space(5);
}
private void DrawFilters()
{
using (new EditorGUILayout.HorizontalScope())
{
GUILayout.Label("Show:", GUILayout.Width(40));
showErrors = GUILayout.Toggle(showErrors, "Errors", EditorStyles.miniButtonLeft);
showWarnings = GUILayout.Toggle(showWarnings, "Warnings", EditorStyles.miniButtonMid);
showInfo = GUILayout.Toggle(showInfo, "Info", EditorStyles.miniButtonRight);
GUILayout.FlexibleSpace();
}
EditorGUILayout.Space(5);
}
private void DrawResults()
{
using (var scrollView = new EditorGUILayout.ScrollViewScope(scrollPosition))
{
scrollPosition = scrollView.scrollPosition;
if (!hasValidated)
{
EditorGUILayout.HelpBox("Click 'Validate Scene' to check for PS1 compatibility issues.", MessageType.Info);
return;
}
if (validationResults.Count == 0)
{
EditorGUILayout.HelpBox("No issues found! Your scene looks ready for PS1 export.", MessageType.Info);
return;
}
foreach (var result in validationResults)
{
if (result.Type == ValidationType.Error && !showErrors) continue;
if (result.Type == ValidationType.Warning && !showWarnings) continue;
if (result.Type == ValidationType.Info && !showInfo) continue;
DrawValidationResult(result);
}
}
}
private void DrawValidationResult(ValidationResult result)
{
MessageType msgType = result.Type switch
{
ValidationType.Error => MessageType.Error,
ValidationType.Warning => MessageType.Warning,
_ => MessageType.Info
};
using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox))
{
EditorGUILayout.HelpBox(result.Message, msgType);
if (result.RelatedObject != null)
{
using (new EditorGUILayout.HorizontalScope())
{
GUILayout.Label("Object:", GUILayout.Width(50));
if (GUILayout.Button(result.RelatedObject.name, EditorStyles.linkLabel))
{
Selection.activeObject = result.RelatedObject;
EditorGUIUtility.PingObject(result.RelatedObject);
}
GUILayout.FlexibleSpace();
if (!string.IsNullOrEmpty(result.FixAction))
{
if (GUILayout.Button("Fix", GUILayout.Width(50)))
{
ApplyFix(result);
}
}
}
}
}
EditorGUILayout.Space(2);
}
private void DrawFooter()
{
EditorGUILayout.Space(10);
using (new EditorGUILayout.HorizontalScope())
{
if (GUILayout.Button("Select All With Errors"))
{
var errorObjects = validationResults
.Where(r => r.Type == ValidationType.Error && r.RelatedObject != null)
.Select(r => r.RelatedObject)
.Distinct()
.ToArray();
Selection.objects = errorObjects;
}
}
}
private void ValidateScene()
{
validationResults.Clear();
errorCount = 0;
warningCount = 0;
infoCount = 0;
// Check for scene exporter
ValidateSceneExporter();
// Check all PSX objects
ValidatePSXObjects();
// Check textures and VRAM
ValidateTextures();
// Check Lua files
ValidateLuaFiles();
// Overall scene stats
ValidateSceneStats();
hasValidated = true;
Repaint();
}
private void ValidateSceneExporter()
{
var exporters = Object.FindObjectsOfType<PSXSceneExporter>();
if (exporters.Length == 0)
{
AddResult(ValidationType.Error,
"No PSXSceneExporter found in scene. Add one via GameObject > PlayStation 1 > Scene Exporter",
null, "AddExporter");
}
else if (exporters.Length > 1)
{
AddResult(ValidationType.Warning,
$"Multiple PSXSceneExporters found ({exporters.Length}). Only one is needed per scene.",
exporters[0].gameObject);
}
}
private void ValidatePSXObjects()
{
var exporters = Object.FindObjectsOfType<PSXObjectExporter>();
if (exporters.Length == 0)
{
AddResult(ValidationType.Info,
"No objects marked for PSX export. Add PSXObjectExporter components to GameObjects you want to export.",
null);
return;
}
foreach (var exporter in exporters)
{
ValidateSingleObject(exporter);
}
}
private void ValidateSingleObject(PSXObjectExporter exporter)
{
var go = exporter.gameObject;
// Check for mesh
var meshFilter = go.GetComponent<MeshFilter>();
if (meshFilter == null || meshFilter.sharedMesh == null)
{
AddResult(ValidationType.Warning,
$"'{go.name}' has no mesh. It will be exported as an empty object.",
go);
return;
}
var mesh = meshFilter.sharedMesh;
int triCount = mesh.triangles.Length / 3;
// Check triangle count
if (triCount > MAX_RECOMMENDED_TRIS_PER_OBJECT)
{
AddResult(ValidationType.Warning,
$"'{go.name}' has {triCount} triangles (recommended max: {MAX_RECOMMENDED_TRIS_PER_OBJECT}). Consider simplifying.",
go);
}
// Check vertex coordinates for GTE limits
var vertices = mesh.vertices;
var transform = go.transform;
bool hasOutOfBounds = false;
foreach (var vert in vertices)
{
var worldPos = transform.TransformPoint(vert);
// Check if fixed-point conversion would overflow (assuming scale factor)
float scaledX = worldPos.x * 4096f; // FixedPoint<12> scale
float scaledY = worldPos.y * 4096f;
float scaledZ = worldPos.z * 4096f;
if (scaledX > MAX_VERTEX_COORD || scaledX < MIN_VERTEX_COORD ||
scaledY > MAX_VERTEX_COORD || scaledY < MIN_VERTEX_COORD ||
scaledZ > MAX_VERTEX_COORD || scaledZ < MIN_VERTEX_COORD)
{
hasOutOfBounds = true;
break;
}
}
if (hasOutOfBounds)
{
AddResult(ValidationType.Error,
$"'{go.name}' has vertices that exceed PS1 coordinate limits. Move closer to origin or scale down.",
go);
}
// Check for renderer and material
var renderer = go.GetComponent<MeshRenderer>();
if (renderer == null)
{
AddResult(ValidationType.Info,
$"'{go.name}' has no MeshRenderer. Will be exported without visual rendering.",
go);
}
else if (renderer.sharedMaterial == null)
{
AddResult(ValidationType.Warning,
$"'{go.name}' has no material assigned. Will use default colors.",
go);
}
// Check texture settings on exporter
if (exporter.texture != null)
{
ValidateTexture(exporter.texture, go);
}
}
private void ValidateTextures()
{
var exporters = Object.FindObjectsOfType<PSXObjectExporter>();
var textures = exporters
.Where(e => e.texture != null)
.Select(e => e.texture)
.Distinct()
.ToList();
if (textures.Count == 0)
{
AddResult(ValidationType.Info,
"No textures assigned to any PSX objects. Scene will be vertex-colored only.",
null);
return;
}
// Rough VRAM estimation
int estimatedVramUsage = 0;
foreach (var tex in textures)
{
// Rough estimate: width * height * bits/8
// This is simplified - actual packing is more complex
int bitsPerPixel = 16; // Assume 16bpp worst case
estimatedVramUsage += (tex.width * tex.height * bitsPerPixel) / 8;
}
int vramTotal = VRAM_WIDTH * VRAM_HEIGHT * 2; // 16bpp
int vramAvailable = vramTotal / 2; // Assume half for framebuffers
if (estimatedVramUsage > vramAvailable)
{
AddResult(ValidationType.Warning,
$"Estimated texture VRAM usage ({estimatedVramUsage / 1024}KB) may exceed available space (~{vramAvailable / 1024}KB). " +
"Consider using lower bit depths or smaller textures.",
null);
}
}
private void ValidateTexture(Texture2D texture, GameObject relatedObject)
{
// Check power of 2
if (!Mathf.IsPowerOfTwo(texture.width) || !Mathf.IsPowerOfTwo(texture.height))
{
AddResult(ValidationType.Warning,
$"Texture '{texture.name}' dimensions ({texture.width}x{texture.height}) are not power of 2. May cause issues.",
relatedObject);
}
// Check max size
if (texture.width > 256 || texture.height > 256)
{
AddResult(ValidationType.Warning,
$"Texture '{texture.name}' is large ({texture.width}x{texture.height}). Consider using 256x256 or smaller.",
relatedObject);
}
}
private void ValidateLuaFiles()
{
var exporters = Object.FindObjectsOfType<PSXObjectExporter>();
foreach (var exporter in exporters)
{
if (exporter.LuaFile != null)
{
// Check if Lua file exists and is valid
string path = AssetDatabase.GetAssetPath(exporter.LuaFile);
if (string.IsNullOrEmpty(path))
{
AddResult(ValidationType.Error,
$"'{exporter.name}' references an invalid Lua file.",
exporter.gameObject);
}
}
}
}
private void ValidateSceneStats()
{
var exporters = Object.FindObjectsOfType<PSXObjectExporter>();
int totalTris = 0;
foreach (var exporter in exporters)
{
var mf = exporter.GetComponent<MeshFilter>();
if (mf != null && mf.sharedMesh != null)
{
totalTris += mf.sharedMesh.triangles.Length / 3;
}
}
AddResult(ValidationType.Info,
$"Scene statistics: {exporters.Length} objects, {totalTris} total triangles.",
null);
if (totalTris > MAX_RECOMMENDED_TOTAL_TRIS)
{
AddResult(ValidationType.Warning,
$"Total triangle count ({totalTris}) exceeds recommended maximum ({MAX_RECOMMENDED_TOTAL_TRIS}). " +
"Performance may be poor on real hardware.",
null);
}
}
private void AddResult(ValidationType type, string message, GameObject relatedObject, string fixAction = null)
{
validationResults.Add(new ValidationResult
{
Type = type,
Message = message,
RelatedObject = relatedObject,
FixAction = fixAction
});
switch (type)
{
case ValidationType.Error: errorCount++; break;
case ValidationType.Warning: warningCount++; break;
case ValidationType.Info: infoCount++; break;
}
}
private void ApplyFix(ValidationResult result)
{
switch (result.FixAction)
{
case "AddExporter":
var go = new GameObject("PSXSceneExporter");
go.AddComponent<PSXSceneExporter>();
Undo.RegisterCreatedObjectUndo(go, "Create PSX Scene Exporter");
Selection.activeGameObject = go;
ValidateScene(); // Re-validate
break;
}
}
private enum ValidationType
{
Error,
Warning,
Info
}
private class ValidationResult
{
public ValidationType Type;
public string Message;
public GameObject RelatedObject;
public string FixAction;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0a26bf89301a2554ca287b9e28e44906

View File

@@ -0,0 +1,203 @@
using UnityEngine;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using System.Threading.Tasks;
using System;
namespace SplashEdit.EditorCode
{
public static class PSXSplashInstaller
{
public static readonly string RepoUrl = "https://github.com/psxsplash/psxsplash.git";
public static readonly string InstallPath = "Assets/psxsplash";
public static readonly string FullInstallPath;
static PSXSplashInstaller()
{
FullInstallPath = Path.Combine(Application.dataPath, "psxsplash");
}
public static bool IsInstalled()
{
return Directory.Exists(FullInstallPath) &&
Directory.EnumerateFileSystemEntries(FullInstallPath).Any();
}
public static async Task<bool> Install()
{
if (IsInstalled()) return true;
try
{
// Create the parent directory if it doesn't exist
Directory.CreateDirectory(Application.dataPath);
// Clone the repository
var result = await RunGitCommandAsync($"clone --recursive {RepoUrl} \"{FullInstallPath}\"", Application.dataPath);
return !result.Contains("error");
}
catch (Exception e)
{
UnityEngine.Debug.LogError($"Failed to install PSXSplash: {e.Message}");
return false;
}
}
public static async Task<Dictionary<string, string>> GetBranchesWithLatestCommitsAsync()
{
if (!IsInstalled()) return new Dictionary<string, string>();
try
{
// Fetch all branches and tags
await RunGitCommandAsync("fetch --all", FullInstallPath);
// Get all remote branches
var branchesOutput = await RunGitCommandAsync("branch -r", FullInstallPath);
var branches = branchesOutput.Split('\n')
.Where(b => !string.IsNullOrEmpty(b.Trim()))
.Select(b => b.Trim().Replace("origin/", ""))
.Where(b => !b.Contains("HEAD"))
.ToList();
var branchesWithCommits = new Dictionary<string, string>();
// Get the latest commit for each branch
foreach (var branch in branches)
{
var commitOutput = await RunGitCommandAsync($"log origin/{branch} -1 --pretty=format:%h", FullInstallPath);
if (!string.IsNullOrEmpty(commitOutput))
{
branchesWithCommits[branch] = commitOutput.Trim();
}
}
return branchesWithCommits;
}
catch (Exception e)
{
UnityEngine.Debug.LogError($"Failed to get branches: {e.Message}");
return new Dictionary<string, string>();
}
}
public static async Task<List<string>> GetReleasesAsync()
{
if (!IsInstalled()) return new List<string>();
try
{
await RunGitCommandAsync("fetch --tags", FullInstallPath);
var output = await RunGitCommandAsync("tag -l", FullInstallPath);
return output.Split('\n')
.Where(t => !string.IsNullOrEmpty(t.Trim()))
.Select(t => t.Trim())
.ToList();
}
catch (Exception e)
{
UnityEngine.Debug.LogError($"Failed to get releases: {e.Message}");
return new List<string>();
}
}
public static async Task<bool> CheckoutVersionAsync(string version)
{
if (!IsInstalled()) return false;
try
{
// If it's a branch name, checkout the branch
// If it's a commit hash, checkout the commit
var result = await RunGitCommandAsync($"checkout {version}", FullInstallPath);
var result2 = await RunGitCommandAsync("submodule update --init --recursive", FullInstallPath);
return !result.Contains("error") && !result2.Contains("error");
}
catch (Exception e)
{
UnityEngine.Debug.LogError($"Failed to checkout version: {e.Message}");
return false;
}
}
public static async Task<bool> FetchLatestAsync()
{
if (!IsInstalled()) return false;
try
{
var result = await RunGitCommandAsync("fetch --all", FullInstallPath);
return !result.Contains("error");
}
catch (Exception e)
{
UnityEngine.Debug.LogError($"Failed to fetch latest: {e.Message}");
return false;
}
}
private static async Task<string> RunGitCommandAsync(string arguments, string workingDirectory)
{
var processInfo = new ProcessStartInfo
{
FileName = "git",
Arguments = arguments,
WorkingDirectory = workingDirectory,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using (var process = new Process())
{
process.StartInfo = processInfo;
var outputBuilder = new System.Text.StringBuilder();
var errorBuilder = new System.Text.StringBuilder();
process.OutputDataReceived += (sender, e) =>
{
if (!string.IsNullOrEmpty(e.Data))
outputBuilder.AppendLine(e.Data);
};
process.ErrorDataReceived += (sender, e) =>
{
if (!string.IsNullOrEmpty(e.Data))
errorBuilder.AppendLine(e.Data);
};
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
// Wait for exit with timeout
var timeout = TimeSpan.FromSeconds(30);
if (await Task.Run(() => process.WaitForExit((int)timeout.TotalMilliseconds)))
{
process.WaitForExit(); // Ensure all output is processed
string output = outputBuilder.ToString();
string error = errorBuilder.ToString();
if (!string.IsNullOrEmpty(error))
{
UnityEngine.Debug.LogError($"Git error: {error}");
}
return output;
}
else
{
process.Kill();
throw new TimeoutException("Git command timed out");
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 72d1da27a16f0794cb1ad49c00799e74

View File

@@ -17,7 +17,7 @@ namespace SplashEdit.EditorCode
private PSXBPP bpp = PSXBPP.TEX_4BIT; private PSXBPP bpp = PSXBPP.TEX_4BIT;
private readonly int previewSize = 256; private readonly int previewSize = 256;
[MenuItem("Window/Quantized Preview")] [MenuItem("PlayStation 1/Quantized Preview")]
public static void ShowWindow() public static void ShowWindow()
{ {
// Creates and displays the window // Creates and displays the window

View File

@@ -0,0 +1,84 @@
using UnityEngine;
using System.Diagnostics;
using System.Linq;
using System.IO;
using System;
/// <summary>
/// Utility that detects whether required build tools (MIPS cross-compiler,
/// GNU Make, GDB, etc.) are available on the host system by probing the
/// PATH via <c>where</c> (Windows) or <c>which</c> (Unix).
/// </summary>
namespace SplashEdit.EditorCode
{
public static class ToolchainChecker
{
private static readonly string[] mipsToolSuffixes = new[]
{
"addr2line", "ar", "as", "cpp", "elfedit", "g++", "gcc", "gcc-ar", "gcc-nm",
"gcc-ranlib", "gcov", "ld", "nm", "objcopy", "objdump", "ranlib", "readelf",
"size", "strings", "strip"
};
/// <summary>
/// Returns the full tool names to be checked, based on platform.
/// </summary>
public static string[] GetRequiredTools()
{
string prefix = Application.platform == RuntimePlatform.WindowsEditor
? "mipsel-none-elf-"
: "mipsel-linux-gnu-";
return mipsToolSuffixes.Select(s => prefix + s).ToArray();
}
/// <summary>
/// Checks for availability of any tool (either full name like "make" or "mipsel-*").
/// </summary>
public static bool IsToolAvailable(string toolName)
{
string command = Application.platform == RuntimePlatform.WindowsEditor ? "where" : "which";
try
{
Process process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = command,
Arguments = toolName,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
string output = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit();
if (!string.IsNullOrEmpty(output))
return true;
// Additional fallback for MIPS tools on Windows in local MIPS path
if (Application.platform == RuntimePlatform.WindowsEditor &&
toolName.StartsWith("mipsel-none-elf-"))
{
string localMipsBin = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"mips", "mips", "bin");
string fullPath = Path.Combine(localMipsBin, toolName + ".exe");
return File.Exists(fullPath);
}
return false;
}
catch
{
return false;
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 142296fdef504c64bb08110e6f28e581

View File

@@ -0,0 +1,137 @@
using System;
using System.Diagnostics;
using System.Threading.Tasks;
using UnityEngine;
using UnityEditor;
using System.IO;
namespace SplashEdit.EditorCode
{
/// <summary>
/// Installs the MIPS cross-compiler toolchain and GNU Make.
/// Supports Windows and Linux only.
/// </summary>
public static class ToolchainInstaller
{
private static bool _installing;
public static string MipsVersion = "14.2.0";
/// <summary>
/// Runs an external process and waits for it to exit.
/// </summary>
public static async Task RunCommandAsync(string fileName, string arguments, string workingDirectory = "")
{
if (fileName.Equals("mips", StringComparison.OrdinalIgnoreCase))
{
fileName = "powershell";
string roamingPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
string scriptPath = Path.Combine(roamingPath, "mips", "mips.ps1");
arguments = $"-ExecutionPolicy Bypass -File \"{scriptPath}\" {arguments}";
}
var tcs = new TaskCompletionSource<int>();
Process process = new Process();
process.StartInfo.FileName = fileName;
process.StartInfo.Arguments = arguments;
process.StartInfo.CreateNoWindow = false;
process.StartInfo.UseShellExecute = true;
if (!string.IsNullOrEmpty(workingDirectory))
process.StartInfo.WorkingDirectory = workingDirectory;
process.EnableRaisingEvents = true;
process.Exited += (sender, args) =>
{
tcs.SetResult(process.ExitCode);
process.Dispose();
};
process.Start();
int exitCode = await tcs.Task;
if (exitCode != 0)
throw new Exception($"Process '{fileName}' exited with code {exitCode}");
}
/// <summary>
/// Installs the MIPS GCC cross-compiler for the current platform.
/// </summary>
public static async Task<bool> InstallToolchain()
{
if (_installing) return false;
_installing = true;
try
{
if (Application.platform == RuntimePlatform.WindowsEditor)
{
if (!ToolchainChecker.IsToolAvailable("mips"))
{
await RunCommandAsync("powershell",
"-c \"& { iwr -UseBasicParsing https://raw.githubusercontent.com/grumpycoders/pcsx-redux/main/mips.ps1 | iex }\"");
EditorUtility.DisplayDialog("Reboot Required",
"Installing the MIPS toolchain requires a reboot. Please reboot and try again.",
"OK");
return false;
}
else
{
await RunCommandAsync("mips", $"install {MipsVersion}");
}
}
else if (Application.platform == RuntimePlatform.LinuxEditor)
{
if (ToolchainChecker.IsToolAvailable("apt"))
await RunCommandAsync("pkexec", "apt install g++-mipsel-linux-gnu -y");
else if (ToolchainChecker.IsToolAvailable("trizen"))
await RunCommandAsync("trizen", "-S cross-mipsel-linux-gnu-binutils cross-mipsel-linux-gnu-gcc");
else
throw new Exception("Unsupported Linux distribution. Install mipsel-linux-gnu-gcc manually.");
}
else
{
throw new Exception("Only Windows and Linux are supported.");
}
return true;
}
catch (Exception ex)
{
EditorUtility.DisplayDialog("Error",
$"Toolchain installation failed: {ex.Message}", "OK");
return false;
}
finally
{
_installing = false;
}
}
/// <summary>
/// Installs GNU Make. On Windows it is bundled with the MIPS toolchain.
/// </summary>
public static async Task InstallMake()
{
if (Application.platform == RuntimePlatform.WindowsEditor)
{
bool proceed = EditorUtility.DisplayDialog(
"Install GNU Make",
"On Windows, GNU Make is included with the MIPS toolchain installer. Install the full toolchain?",
"Yes", "No");
if (proceed) await InstallToolchain();
}
else if (Application.platform == RuntimePlatform.LinuxEditor)
{
if (ToolchainChecker.IsToolAvailable("apt"))
await RunCommandAsync("pkexec", "apt install build-essential -y");
else
throw new Exception("Unsupported Linux distribution. Install 'make' manually.");
}
else
{
throw new Exception("Only Windows and Linux are supported.");
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c5aa88b01a3eef145806c8e9e59f4e9d

View File

@@ -37,7 +37,7 @@ namespace SplashEdit.EditorCode
}; };
private static string[] resolutionsStrings => resolutions.Select(c => $"{c.x}x{c.y}").ToArray(); private static string[] resolutionsStrings => resolutions.Select(c => $"{c.x}x{c.y}").ToArray();
[MenuItem("Window/VRAM Editor")] [MenuItem("PlayStation 1/VRAM Editor")]
public static void ShowWindow() public static void ShowWindow()
{ {
VRAMEditorWindow window = GetWindow<VRAMEditorWindow>("VRAM Editor"); VRAMEditorWindow window = GetWindow<VRAMEditorWindow>("VRAM Editor");
@@ -299,11 +299,5 @@ namespace SplashEdit.EditorCode
GUILayout.EndHorizontal(); GUILayout.EndHorizontal();
} }
/// <summary>
/// Stores current configuration to the PSX data asset.
/// This is now triggered manually via the "Save Settings" button.
/// </summary>
} }
} }

View File

@@ -1,10 +1,13 @@
{ {
"name": "net.psxplash.splashedit.Editor", "name": "net.psxsplash.splashedit.Editor",
"rootNamespace": "", "rootNamespace": "",
"references": [ "references": [
"net.psxsplash.splashedit.Runtime" "net.psxsplash.splashedit.Runtime",
"Unity.AI.Navigation"
],
"includePlatforms": [
"Editor"
], ],
"includePlatforms": [],
"excludePlatforms": [], "excludePlatforms": [],
"allowUnsafeCode": false, "allowUnsafeCode": false,
"overrideReferences": false, "overrideReferences": false,

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 7e3500b5974da9723bdd0d457348ea2d guid: 8bf64a45e6e447140a68258cd60d0ec1
AssemblyDefinitionImporter: AssemblyDefinitionImporter:
externalObjects: {} externalObjects: {}
userData: userData:

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: ab7e1dbd79d3e1101b7d44cdf06a2991 guid: f1210e43ecf5c354486bc01af97ba9eb
folderAsset: yes folderAsset: yes
DefaultImporter: DefaultImporter:
externalObjects: {} externalObjects: {}

View File

@@ -1,12 +1,12 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: d695ef52da250cdcea6c30ab1122c56e guid: 55ec05596eb659341b8fdb46cd21ab63
TextureImporter: TextureImporter:
internalIDToNameTable: [] internalIDToNameTable: []
externalObjects: {} externalObjects: {}
serializedVersion: 13 serializedVersion: 13
mipmaps: mipmaps:
mipMapMode: 0 mipMapMode: 0
enableMipMap: 0 enableMipMap: 1
sRGBTexture: 1 sRGBTexture: 1
linearTexture: 0 linearTexture: 0
fadeOut: 0 fadeOut: 0
@@ -37,10 +37,10 @@ TextureImporter:
filterMode: 1 filterMode: 1
aniso: 1 aniso: 1
mipBias: 0 mipBias: 0
wrapU: 1 wrapU: 0
wrapV: 1 wrapV: 0
wrapW: 0 wrapW: 0
nPOTScale: 0 nPOTScale: 1
lightmap: 0 lightmap: 0
compressionQuality: 50 compressionQuality: 50
spriteMode: 0 spriteMode: 0
@@ -52,9 +52,9 @@ TextureImporter:
spriteBorder: {x: 0, y: 0, z: 0, w: 0} spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1 spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1 alphaUsage: 1
alphaIsTransparency: 1 alphaIsTransparency: 0
spriteTessellationDetail: -1 spriteTessellationDetail: -1
textureType: 2 textureType: 0
textureShape: 1 textureShape: 1
singleChannelComponent: 0 singleChannelComponent: 0
flipbookRows: 1 flipbookRows: 1
@@ -94,7 +94,20 @@ TextureImporter:
androidETC2FallbackOverride: 0 androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0 forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4 - serializedVersion: 4
buildTarget: WebGL buildTarget: Android
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: iOS
maxTextureSize: 2048 maxTextureSize: 2048
resizeAlgorithm: 0 resizeAlgorithm: 0
textureFormat: -1 textureFormat: -1

View File

@@ -1,12 +1,12 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 4d7bd095e76e6f3df976224b15405059 guid: 67d31a8682e9646419734c5412403992
TextureImporter: TextureImporter:
internalIDToNameTable: [] internalIDToNameTable: []
externalObjects: {} externalObjects: {}
serializedVersion: 13 serializedVersion: 13
mipmaps: mipmaps:
mipMapMode: 0 mipMapMode: 0
enableMipMap: 0 enableMipMap: 1
sRGBTexture: 1 sRGBTexture: 1
linearTexture: 0 linearTexture: 0
fadeOut: 0 fadeOut: 0
@@ -37,10 +37,10 @@ TextureImporter:
filterMode: 1 filterMode: 1
aniso: 1 aniso: 1
mipBias: 0 mipBias: 0
wrapU: 1 wrapU: 0
wrapV: 1 wrapV: 0
wrapW: 0 wrapW: 0
nPOTScale: 0 nPOTScale: 1
lightmap: 0 lightmap: 0
compressionQuality: 50 compressionQuality: 50
spriteMode: 0 spriteMode: 0
@@ -52,9 +52,9 @@ TextureImporter:
spriteBorder: {x: 0, y: 0, z: 0, w: 0} spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1 spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1 alphaUsage: 1
alphaIsTransparency: 1 alphaIsTransparency: 0
spriteTessellationDetail: -1 spriteTessellationDetail: -1
textureType: 2 textureType: 0
textureShape: 1 textureShape: 1
singleChannelComponent: 0 singleChannelComponent: 0
flipbookRows: 1 flipbookRows: 1
@@ -94,7 +94,20 @@ TextureImporter:
androidETC2FallbackOverride: 0 androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0 forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4 - serializedVersion: 4
buildTarget: WebGL buildTarget: Android
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: iOS
maxTextureSize: 2048 maxTextureSize: 2048
resizeAlgorithm: 0 resizeAlgorithm: 0
textureFormat: -1 textureFormat: -1

View File

@@ -1,12 +1,12 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: e11677149a517ca5186e32dfda3ec088 guid: 5a5f4bcf472dcfc44b794a898530a6f0
TextureImporter: TextureImporter:
internalIDToNameTable: [] internalIDToNameTable: []
externalObjects: {} externalObjects: {}
serializedVersion: 13 serializedVersion: 13
mipmaps: mipmaps:
mipMapMode: 0 mipMapMode: 0
enableMipMap: 0 enableMipMap: 1
sRGBTexture: 1 sRGBTexture: 1
linearTexture: 0 linearTexture: 0
fadeOut: 0 fadeOut: 0
@@ -37,10 +37,10 @@ TextureImporter:
filterMode: 1 filterMode: 1
aniso: 1 aniso: 1
mipBias: 0 mipBias: 0
wrapU: 1 wrapU: 0
wrapV: 1 wrapV: 0
wrapW: 0 wrapW: 0
nPOTScale: 0 nPOTScale: 1
lightmap: 0 lightmap: 0
compressionQuality: 50 compressionQuality: 50
spriteMode: 0 spriteMode: 0
@@ -52,9 +52,9 @@ TextureImporter:
spriteBorder: {x: 0, y: 0, z: 0, w: 0} spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1 spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1 alphaUsage: 1
alphaIsTransparency: 1 alphaIsTransparency: 0
spriteTessellationDetail: -1 spriteTessellationDetail: -1
textureType: 2 textureType: 0
textureShape: 1 textureShape: 1
singleChannelComponent: 0 singleChannelComponent: 0
flipbookRows: 1 flipbookRows: 1
@@ -94,7 +94,20 @@ TextureImporter:
androidETC2FallbackOverride: 0 androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0 forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4 - serializedVersion: 4
buildTarget: WebGL buildTarget: Android
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: iOS
maxTextureSize: 2048 maxTextureSize: 2048
resizeAlgorithm: 0 resizeAlgorithm: 0
textureFormat: -1 textureFormat: -1

View File

@@ -1,12 +1,12 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 0be7a2d4700082dbc83b9274837c70bc guid: 44fe191eb81c12e4698ab9e87406b878
TextureImporter: TextureImporter:
internalIDToNameTable: [] internalIDToNameTable: []
externalObjects: {} externalObjects: {}
serializedVersion: 13 serializedVersion: 13
mipmaps: mipmaps:
mipMapMode: 0 mipMapMode: 0
enableMipMap: 0 enableMipMap: 1
sRGBTexture: 1 sRGBTexture: 1
linearTexture: 0 linearTexture: 0
fadeOut: 0 fadeOut: 0
@@ -37,10 +37,10 @@ TextureImporter:
filterMode: 1 filterMode: 1
aniso: 1 aniso: 1
mipBias: 0 mipBias: 0
wrapU: 1 wrapU: 0
wrapV: 1 wrapV: 0
wrapW: 0 wrapW: 0
nPOTScale: 0 nPOTScale: 1
lightmap: 0 lightmap: 0
compressionQuality: 50 compressionQuality: 50
spriteMode: 0 spriteMode: 0
@@ -52,9 +52,9 @@ TextureImporter:
spriteBorder: {x: 0, y: 0, z: 0, w: 0} spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1 spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1 alphaUsage: 1
alphaIsTransparency: 1 alphaIsTransparency: 0
spriteTessellationDetail: -1 spriteTessellationDetail: -1
textureType: 2 textureType: 0
textureShape: 1 textureShape: 1
singleChannelComponent: 0 singleChannelComponent: 0
flipbookRows: 1 flipbookRows: 1
@@ -94,7 +94,20 @@ TextureImporter:
androidETC2FallbackOverride: 0 androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0 forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4 - serializedVersion: 4
buildTarget: WebGL buildTarget: Android
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: iOS
maxTextureSize: 2048 maxTextureSize: 2048
resizeAlgorithm: 0 resizeAlgorithm: 0
textureFormat: -1 textureFormat: -1

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: c1679c9d58898f14494d614dfe5f76a6 guid: 07933442bdb4ee14f83fd0b0d0144b8a
DefaultImporter: DefaultImporter:
externalObjects: {} externalObjects: {}
userData: userData:

8
Plugins.meta Normal file
View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

8
Plugins/DotRecast.meta Normal file
View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

View File

@@ -0,0 +1,27 @@
fileFormatVersion: 2
guid: c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8
PluginImporter:
externalObjects: {}
serializedVersion: 2
iconMap: {}
executionOrder: {}
defineConstraints: []
isPreloaded: 0
isOverridable: 0
isExplicitlyReferenced: 0
validateReferences: 1
platformData:
- first:
Any:
second:
enabled: 1
settings: {}
- first:
Editor: Editor
second:
enabled: 1
settings:
DefaultValueInitialized: true
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

View File

@@ -0,0 +1,27 @@
fileFormatVersion: 2
guid: d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9
PluginImporter:
externalObjects: {}
serializedVersion: 2
iconMap: {}
executionOrder: {}
defineConstraints: []
isPreloaded: 0
isOverridable: 0
isExplicitlyReferenced: 0
validateReferences: 1
platformData:
- first:
Any:
second:
enabled: 1
settings: {}
- first:
Editor: Editor
second:
enabled: 1
settings:
DefaultValueInitialized: true
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -52,13 +52,11 @@ There are currently four custom Unity components for exporting your scenes to th
**PSX Player**: Attach this to any GameObject that you want to act as the player. For now, it acts as a FPS / Free Camera template. The analogue does work for it if you have the option specified in your choice emulator. **PSX Player**: Attach this to any GameObject that you want to act as the player. For now, it acts as a FPS / Free Camera template. The analogue does work for it if you have the option specified in your choice emulator.
**PSX Nav Mesh**: Attach this to any GameObject that you want to apply collisions to. These collisions are rudimentary and currently only work in a top-down view. There must be at least one existing GameObject that uses the **PSX Player** component for this to work properly.
## Before Exporting: ## Before Exporting:
Check the following: Check the following:
**Player and Nav Meshes**: In case it wasn't clear already, a **PSX Player** component and **PSX Nav Mesh** component must coexist in the same scene for the player to walk on the collisions properly - as both rely on eachother to work. **Player and Navigation**: A **PSX Player** component must exist in the scene. Navigation is handled by the Nav Region system, which is built automatically from objects with ``Generate Navigation`` enabled in their **PSX Object Exporter** settings.
**Scaling**: Ensure that your Geometry Transformation Engine (or GTE) scaling is set to a reasonable value, which can be found in your GameObject holding the component. Ideally, each object in your scene should be within the GTE bounds (or red wireframe bounding box). Bandwidth does mention that there is a scaling / overflow bug with Nav Meshes being worked on and the way to circumvent this is by scaling up the GTE value to be higher. **Scaling**: Ensure that your Geometry Transformation Engine (or GTE) scaling is set to a reasonable value, which can be found in your GameObject holding the component. Ideally, each object in your scene should be within the GTE bounds (or red wireframe bounding box). Bandwidth does mention that there is a scaling / overflow bug with Nav Meshes being worked on and the way to circumvent this is by scaling up the GTE value to be higher.
@@ -76,11 +74,11 @@ Convert and preview your textures in a PSX-compatible format.
**Light Baking**:\ **Light Baking**:\
Prebakes any lights in the scene into the vertex colors of the nearest triangles in the exported mesh data. Prebakes any lights in the scene into the vertex colors of the nearest triangles in the exported mesh data.
**Nav Mesh Generation**:\ **Nav Region Generation**:\
Generates a new Nav Mesh for FPS movement in your scene. Automatically builds convex navigation regions from walkable surfaces for efficient player movement and floor resolution.
**FPS / Free Camera Template**:\ **FPS / Free Camera Template**:\
Allows you to walk around in your scene or view it from different perspectives. It's really just the way **PSX Player** and **PSX Nav Mesh** components are setup right now. Allows you to walk around in your scene or view it from different perspectives using the **PSX Player** component.
## Additional Features: ## Additional Features:
**VRAM Editor**: **VRAM Editor**:

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 4df40ce535b32f3a4b30ce0803fa699a guid: 589dcafb532c388449644bfeb4cf5178
TextScriptImporter: TextScriptImporter:
externalObjects: {} externalObjects: {}
userData: userData:

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 49f302f0a6255c06c8e340024aa43f45 guid: 383aa9702d932194ea91e4f3488b41e2
folderAsset: yes folderAsset: yes
DefaultImporter: DefaultImporter:
externalObjects: {} externalObjects: {}

393
Runtime/BVH.cs Normal file
View File

@@ -0,0 +1,393 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEngine;
using SplashEdit.RuntimeCode;
namespace SplashEdit.RuntimeCode
{
/// <summary>
/// Bounding Volume Hierarchy for PS1 frustum culling.
/// Unlike BSP, BVH doesn't split triangles - it groups them by spatial locality.
/// This is better for PS1 because:
/// 1. No additional triangles created (memory constrained)
/// 2. Simple AABB tests are fast on 33MHz CPU
/// 3. Natural hierarchical culling
/// </summary>
public class BVH : IPSXBinaryWritable
{
// Configuration
private const int MAX_TRIANGLES_PER_LEAF = 64; // PS1 can handle batches of this size
private const int MAX_DEPTH = 16; // Prevent pathological cases
private const int MIN_TRIANGLES_TO_SPLIT = 8; // Don't split tiny groups
private List<PSXObjectExporter> _objects;
private BVHNode _root;
private List<BVHNode> _allNodes; // Flat list for export
private List<TriangleRef> _allTriangleRefs; // Triangle references for export
public int NodeCount => _allNodes?.Count ?? 0;
public int TriangleRefCount => _allTriangleRefs?.Count ?? 0;
/// <summary>
/// Reference to a triangle - doesn't copy data, just points to it
/// </summary>
public struct TriangleRef
{
public ushort objectIndex; // Which GameObject
public ushort triangleIndex; // Which triangle in that object's mesh
public TriangleRef(int objIdx, int triIdx)
{
objectIndex = (ushort)objIdx;
triangleIndex = (ushort)triIdx;
}
}
/// <summary>
/// BVH Node - 32 bytes when exported
/// </summary>
public class BVHNode
{
public Bounds bounds;
public BVHNode left;
public BVHNode right;
public List<TriangleRef> triangles; // Only for leaf nodes
public int depth;
// Export indices (filled during serialization)
public int nodeIndex = -1;
public int leftIndex = -1; // -1 = no child (leaf check)
public int rightIndex = -1;
public int firstTriangleIndex = -1;
public int triangleCount = 0;
public bool IsLeaf => left == null && right == null;
}
/// <summary>
/// Triangle with bounds for building
/// </summary>
private struct TriangleWithBounds
{
public TriangleRef reference;
public Bounds bounds;
public Vector3 centroid;
}
public BVH(List<PSXObjectExporter> objects)
{
_objects = objects;
_allNodes = new List<BVHNode>();
_allTriangleRefs = new List<TriangleRef>();
}
public void Build()
{
_allNodes.Clear();
_allTriangleRefs.Clear();
// Extract all triangles with their bounds
List<TriangleWithBounds> triangles = ExtractTriangles();
if (triangles.Count == 0)
{
Debug.LogWarning("BVH: No triangles to process");
return;
}
// Build the tree
_root = BuildNode(triangles, 0);
// Flatten for export
FlattenTree();
}
private List<TriangleWithBounds> ExtractTriangles()
{
var result = new List<TriangleWithBounds>();
for (int objIdx = 0; objIdx < _objects.Count; objIdx++)
{
var exporter = _objects[objIdx];
if (!exporter.IsActive) continue;
MeshFilter mf = exporter.GetComponent<MeshFilter>();
if (mf == null || mf.sharedMesh == null) continue;
Mesh mesh = mf.sharedMesh;
Vector3[] vertices = mesh.vertices;
int[] indices = mesh.triangles;
Matrix4x4 worldMatrix = exporter.transform.localToWorldMatrix;
for (int i = 0; i < indices.Length; i += 3)
{
Vector3 v0 = worldMatrix.MultiplyPoint3x4(vertices[indices[i]]);
Vector3 v1 = worldMatrix.MultiplyPoint3x4(vertices[indices[i + 1]]);
Vector3 v2 = worldMatrix.MultiplyPoint3x4(vertices[indices[i + 2]]);
// Calculate bounds
Bounds triBounds = new Bounds(v0, Vector3.zero);
triBounds.Encapsulate(v1);
triBounds.Encapsulate(v2);
result.Add(new TriangleWithBounds
{
reference = new TriangleRef(objIdx, i / 3),
bounds = triBounds,
centroid = (v0 + v1 + v2) / 3f
});
}
}
return result;
}
private BVHNode BuildNode(List<TriangleWithBounds> triangles, int depth)
{
if (triangles.Count == 0)
return null;
var node = new BVHNode { depth = depth };
// Calculate bounds encompassing all triangles
node.bounds = triangles[0].bounds;
foreach (var tri in triangles)
{
node.bounds.Encapsulate(tri.bounds);
}
// Create leaf if conditions met
if (triangles.Count <= MAX_TRIANGLES_PER_LEAF ||
depth >= MAX_DEPTH ||
triangles.Count < MIN_TRIANGLES_TO_SPLIT)
{
node.triangles = triangles.Select(t => t.reference).ToList();
return node;
}
// Find best split axis (longest extent)
Vector3 extent = node.bounds.size;
int axis = 0;
if (extent.y > extent.x && extent.y > extent.z) axis = 1;
else if (extent.z > extent.x && extent.z > extent.y) axis = 2;
// Sort by centroid along chosen axis
triangles.Sort((a, b) =>
{
float va = axis == 0 ? a.centroid.x : (axis == 1 ? a.centroid.y : a.centroid.z);
float vb = axis == 0 ? b.centroid.x : (axis == 1 ? b.centroid.y : b.centroid.z);
return va.CompareTo(vb);
});
// Find split plane position at median centroid
int mid = triangles.Count / 2;
if (mid == 0) mid = 1;
if (mid >= triangles.Count) mid = triangles.Count - 1;
float splitPos = axis == 0 ? triangles[mid].centroid.x :
(axis == 1 ? triangles[mid].centroid.y : triangles[mid].centroid.z);
// Partition triangles - allow overlap for triangles spanning the split plane
var leftTris = new List<TriangleWithBounds>();
var rightTris = new List<TriangleWithBounds>();
foreach (var tri in triangles)
{
float triMin = axis == 0 ? tri.bounds.min.x : (axis == 1 ? tri.bounds.min.y : tri.bounds.min.z);
float triMax = axis == 0 ? tri.bounds.max.x : (axis == 1 ? tri.bounds.max.y : tri.bounds.max.z);
// Triangle spans split plane - add to BOTH children (spatial split)
// This fixes large triangles at screen edges being culled incorrectly
if (triMin < splitPos && triMax > splitPos)
{
leftTris.Add(tri);
rightTris.Add(tri);
}
// Triangle entirely on left side
else if (triMax <= splitPos)
{
leftTris.Add(tri);
}
// Triangle entirely on right side
else
{
rightTris.Add(tri);
}
}
// Check if split is beneficial (prevents infinite recursion on coincident triangles)
if (leftTris.Count == 0 || rightTris.Count == 0 ||
(leftTris.Count == triangles.Count && rightTris.Count == triangles.Count))
{
node.triangles = triangles.Select(t => t.reference).ToList();
return node;
}
node.left = BuildNode(leftTris, depth + 1);
node.right = BuildNode(rightTris, depth + 1);
return node;
}
/// <summary>
/// Flatten tree to arrays for export
/// </summary>
private void FlattenTree()
{
_allNodes.Clear();
_allTriangleRefs.Clear();
if (_root == null) return;
// BFS to assign indices
var queue = new Queue<BVHNode>();
queue.Enqueue(_root);
while (queue.Count > 0)
{
var node = queue.Dequeue();
node.nodeIndex = _allNodes.Count;
_allNodes.Add(node);
if (node.left != null) queue.Enqueue(node.left);
if (node.right != null) queue.Enqueue(node.right);
}
// Second pass: fill in child indices and triangle data
foreach (var node in _allNodes)
{
if (node.left != null)
node.leftIndex = node.left.nodeIndex;
if (node.right != null)
node.rightIndex = node.right.nodeIndex;
if (node.IsLeaf && node.triangles != null && node.triangles.Count > 0)
{
// Sort tri-refs by objectIndex within each leaf so the C++ renderer
// can batch consecutive refs and avoid redundant GTE matrix reloads.
node.triangles.Sort((a, b) => a.objectIndex.CompareTo(b.objectIndex));
node.firstTriangleIndex = _allTriangleRefs.Count;
node.triangleCount = node.triangles.Count;
_allTriangleRefs.AddRange(node.triangles);
}
}
}
/// <summary>
/// Export BVH to binary writer
/// Format:
/// - uint16 nodeCount
/// - uint16 triangleRefCount
/// - BVHNode[nodeCount] (32 bytes each)
/// - TriangleRef[triangleRefCount] (4 bytes each)
/// </summary>
public void WriteToBinary(BinaryWriter writer, float gteScaling)
{
// Note: counts are already in the file header (bvhNodeCount, bvhTriangleRefCount)
// Don't write them again here - C++ reads BVH data directly after colliders
// Write nodes (32 bytes each)
foreach (var node in _allNodes)
{
// AABB bounds (24 bytes)
Vector3 min = node.bounds.min;
Vector3 max = node.bounds.max;
writer.Write(PSXTrig.ConvertWorldToFixed12(min.x / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(-max.y / gteScaling)); // Y flipped
writer.Write(PSXTrig.ConvertWorldToFixed12(min.z / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(max.x / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(-min.y / gteScaling)); // Y flipped
writer.Write(PSXTrig.ConvertWorldToFixed12(max.z / gteScaling));
// Child indices (4 bytes) - 0xFFFF means no child
writer.Write((ushort)(node.leftIndex >= 0 ? node.leftIndex : 0xFFFF));
writer.Write((ushort)(node.rightIndex >= 0 ? node.rightIndex : 0xFFFF));
// Triangle data (4 bytes)
writer.Write((ushort)(node.firstTriangleIndex >= 0 ? node.firstTriangleIndex : 0));
writer.Write((ushort)node.triangleCount);
}
// Write triangle references (4 bytes each)
foreach (var triRef in _allTriangleRefs)
{
writer.Write(triRef.objectIndex);
writer.Write(triRef.triangleIndex);
}
}
/// <summary>
/// Get total bytes that will be written
/// </summary>
public int GetBinarySize()
{
// Just nodes + triangle refs, counts are in file header
return (_allNodes.Count * 32) + (_allTriangleRefs.Count * 4);
}
/// <summary>
/// Draw gizmos for debugging
/// </summary>
public void DrawGizmos(int maxDepth = 999)
{
if (_root == null) return;
DrawNodeGizmos(_root, maxDepth);
}
private void DrawNodeGizmos(BVHNode node, int maxDepth)
{
if (node == null || node.depth > maxDepth) return;
// Color by depth
Color c = Color.HSVToRGB((node.depth * 0.12f) % 1f, 0.7f, 0.9f);
c.a = node.IsLeaf ? 0.3f : 0.1f;
Gizmos.color = c;
// Draw bounds
Gizmos.DrawWireCube(node.bounds.center, node.bounds.size);
if (node.IsLeaf)
{
// Draw leaf as semi-transparent
Gizmos.color = new Color(c.r, c.g, c.b, 0.1f);
Gizmos.DrawCube(node.bounds.center, node.bounds.size);
}
// Recurse
DrawNodeGizmos(node.left, maxDepth);
DrawNodeGizmos(node.right, maxDepth);
}
/// <summary>
/// Get statistics for debugging
/// </summary>
public string GetStatistics()
{
if (_root == null) return "BVH not built";
int leafCount = 0;
int maxDepth = 0;
int totalTris = 0;
void CountNodes(BVHNode node)
{
if (node == null) return;
if (node.depth > maxDepth) maxDepth = node.depth;
if (node.IsLeaf)
{
leafCount++;
totalTris += node.triangleCount;
}
CountNodes(node.left);
CountNodes(node.right);
}
CountNodes(_root);
return $"Nodes: {_allNodes.Count}, Leaves: {leafCount}, Max Depth: {maxDepth}, Triangle Refs: {totalTris}";
}
}
}

2
Runtime/BVH.cs.meta Normal file
View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 735c7edec8b9f5d4facdf22f48d99ee0

8
Runtime/Core.meta Normal file
View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 90864d7c8ee7ae6409c8a0c0a2ea9075
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,18 @@
using System.IO;
namespace SplashEdit.RuntimeCode
{
/// <summary>
/// Implemented by scene-level data builders that serialize their output
/// into the splashpack binary stream.
/// </summary>
public interface IPSXBinaryWritable
{
/// <summary>
/// Write binary data to the splashpack stream.
/// </summary>
/// <param name="writer">The binary writer positioned at the correct offset.</param>
/// <param name="gteScaling">GTE coordinate scaling factor.</param>
void WriteToBinary(BinaryWriter writer, float gteScaling);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: aa53647f0fc3ed24292dd3fd9a0b294e

20
Runtime/IPSXExportable.cs Normal file
View File

@@ -0,0 +1,20 @@
namespace SplashEdit.RuntimeCode
{
/// <summary>
/// Implemented by MonoBehaviours that participate in the PSX scene export pipeline.
/// Each exportable object converts its Unity representation into PSX-ready data.
/// </summary>
public interface IPSXExportable
{
/// <summary>
/// Convert Unity textures into PSX texture data (palette-quantized, packed).
/// </summary>
void CreatePSXTextures2D();
/// <summary>
/// Convert the Unity mesh into a PSX-ready triangle list.
/// </summary>
/// <param name="gteScaling">GTE coordinate scaling factor.</param>
void CreatePSXMesh(float gteScaling);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0598c601ee3672b40828f0d31bbec29b

View File

@@ -70,7 +70,7 @@ namespace SplashEdit.RuntimeCode
List<Vector3> centroids = Enumerable.Range(0, k).Select(i => colors[i * colors.Count / k]).ToList(); List<Vector3> centroids = Enumerable.Range(0, k).Select(i => colors[i * colors.Count / k]).ToList();
List<List<Vector3>> clusters; List<List<Vector3>> clusters;
for (int i = 0; i < 10; i++) // Fixed iterations for performance.... i hate this... for (int i = 0; i < 10; i++) // Fixed iteration count
{ {
clusters = Enumerable.Range(0, k).Select(_ => new List<Vector3>()).ToList(); clusters = Enumerable.Range(0, k).Select(_ => new List<Vector3>()).ToList();
foreach (Vector3 color in colors) foreach (Vector3 color in colors)

View File

@@ -1,2 +1,2 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 1291c85b333132b8392486949420d31a guid: c760e5745d5c72746aec8ac9583c456f

View File

@@ -1,6 +1,6 @@
using UnityEngine; using UnityEngine;
namespace Splashedit.RuntimeCode namespace SplashEdit.RuntimeCode
{ {
public class LuaFile : ScriptableObject public class LuaFile : ScriptableObject
{ {
@@ -13,3 +13,4 @@ namespace Splashedit.RuntimeCode
} }
} }
} }

View File

@@ -1,2 +1,2 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: e3b07239f3beb7a87ad987c3fedae9c1 guid: 00e64fcbfc4e23e4dbe284131fa4d89b

43
Runtime/PSXAudioSource.cs Normal file
View File

@@ -0,0 +1,43 @@
using UnityEngine;
namespace SplashEdit.RuntimeCode
{
/// <summary>
/// Pre-converted audio clip data ready for splashpack serialization.
/// Populated by the Editor (PSXSceneExporter) so Runtime code never
/// touches PSXAudioConverter.
/// </summary>
public struct AudioClipExport
{
public byte[] adpcmData;
public int sampleRate;
public bool loop;
public string clipName;
}
/// <summary>
/// Attach to a GameObject to include an audio clip in the PS1 build.
/// At export time, the AudioClip is converted to SPU ADPCM and packed
/// into the splashpack binary. Use Audio.Play(clipIndex) from Lua.
/// </summary>
[AddComponentMenu("PSX/Audio Source")]
public class PSXAudioSource : MonoBehaviour
{
[Tooltip("Name used to identify this clip in Lua (Audio.Play(\"name\"))." )]
public string ClipName = "";
[Tooltip("Unity AudioClip to convert to PS1 SPU ADPCM format.")]
public AudioClip Clip;
[Tooltip("Target sample rate for the PS1 (lower = smaller, max 44100).")]
[Range(8000, 44100)]
public int SampleRate = 22050;
[Tooltip("Whether this clip should loop when played.")]
public bool Loop = false;
[Tooltip("Default playback volume (0-127).")]
[Range(0, 127)]
public int DefaultVolume = 100;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3c4c3feb30e8c264baddc3a5e774473b

View File

@@ -0,0 +1,357 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEngine;
namespace SplashEdit.RuntimeCode
{
/// <summary>
/// Surface flags for collision triangles — must match C++ SurfaceFlag enum
/// </summary>
[Flags]
public enum PSXSurfaceFlag : byte
{
Solid = 0x01,
Slope = 0x02,
Stairs = 0x04,
Trigger = 0x08,
NoWalk = 0x10,
}
/// <summary>
/// Exports scene collision geometry as a flat world-space triangle soup
/// with per-triangle surface flags and world-space AABBs.
///
/// Binary layout (matches C++ structs):
/// CollisionDataHeader (20 bytes)
/// CollisionMeshHeader[meshCount] (32 bytes each)
/// CollisionTri[triangleCount] (52 bytes each)
/// CollisionChunk[chunkW*chunkH] (4 bytes each, exterior only)
/// </summary>
public class PSXCollisionExporter : IPSXBinaryWritable
{
// Configurable
public float WalkableSlopeAngle = 46.0f; // Degrees; steeper = wall
// Build results
private List<CollisionMesh> _meshes = new List<CollisionMesh>();
private List<CollisionTriExport> _allTriangles = new List<CollisionTriExport>();
private CollisionChunkExport[,] _chunks;
private Vector3 _chunkOrigin;
private float _chunkSize;
private int _chunkGridW, _chunkGridH;
public int MeshCount => _meshes.Count;
public int TriangleCount => _allTriangles.Count;
// Internal types
private class CollisionMesh
{
public Bounds worldAABB;
public int firstTriangle;
public int triangleCount;
public byte roomIndex;
}
private struct CollisionTriExport
{
public Vector3 v0, e1, e2, normal;
public byte flags;
public byte roomIndex;
}
private struct CollisionChunkExport
{
public int firstMeshIndex;
public int meshCount;
}
/// <summary>
/// Build collision data from scene exporters.
/// When autoIncludeSolid is true, objects with CollisionType=None are
/// automatically treated as Solid. This ensures all scene geometry
/// blocks the player without requiring manual flagging.
/// </summary>
public void Build(PSXObjectExporter[] exporters, float gteScaling,
bool autoIncludeSolid = true)
{
_meshes.Clear();
_allTriangles.Clear();
float cosWalkable = Mathf.Cos(WalkableSlopeAngle * Mathf.Deg2Rad);
int autoIncluded = 0;
foreach (var exporter in exporters)
{
PSXCollisionType effectiveType = exporter.CollisionType;
if (effectiveType == PSXCollisionType.None)
{
if (autoIncludeSolid)
{
// Auto-include as Solid so all geometry blocks the player
effectiveType = PSXCollisionType.Solid;
autoIncluded++;
}
else
{
continue;
}
}
// Get the collision mesh (custom or render mesh)
MeshFilter mf = exporter.GetComponent<MeshFilter>();
Mesh collisionMesh = exporter.CustomCollisionMesh != null
? exporter.CustomCollisionMesh
: mf?.sharedMesh;
if (collisionMesh == null)
continue;
Matrix4x4 worldMatrix = exporter.transform.localToWorldMatrix;
Vector3[] vertices = collisionMesh.vertices;
int[] indices = collisionMesh.triangles;
int firstTri = _allTriangles.Count;
Bounds meshBoundsWorld = new Bounds();
bool boundsInit = false;
for (int i = 0; i < indices.Length; i += 3)
{
Vector3 v0 = worldMatrix.MultiplyPoint3x4(vertices[indices[i]]);
Vector3 v1 = worldMatrix.MultiplyPoint3x4(vertices[indices[i + 1]]);
Vector3 v2 = worldMatrix.MultiplyPoint3x4(vertices[indices[i + 2]]);
Vector3 edge1 = v1 - v0;
Vector3 edge2 = v2 - v0;
Vector3 normal = Vector3.Cross(edge1, edge2).normalized;
// Determine surface flags
byte flags = 0;
if (effectiveType == PSXCollisionType.Trigger)
{
flags = (byte)PSXSurfaceFlag.Trigger;
}
else
{
// Floor-like: normal.y > cosWalkable
// Note: Unity Y is up; PS1 Y is down. We export in Unity space
// and convert to PS1 space during WriteToBinary.
float dotUp = normal.y;
if (dotUp > cosWalkable)
{
flags = (byte)PSXSurfaceFlag.Solid;
// Check if stairs (tagged on exporter or steep-ish)
if (exporter.ObjectFlags.HasFlag(PSXObjectFlags.Static) &&
dotUp < 0.95f && dotUp > cosWalkable)
{
flags |= (byte)PSXSurfaceFlag.Stairs;
}
}
else if (dotUp > 0.0f)
{
// Slope too steep to walk on
flags = (byte)(PSXSurfaceFlag.Solid | PSXSurfaceFlag.Slope);
}
else
{
// Wall or ceiling
flags = (byte)PSXSurfaceFlag.Solid;
}
}
_allTriangles.Add(new CollisionTriExport
{
v0 = v0,
e1 = edge1,
e2 = edge2,
normal = normal,
flags = flags,
roomIndex = 0xFF,
});
// Update world bounds
if (!boundsInit)
{
meshBoundsWorld = new Bounds(v0, Vector3.zero);
boundsInit = true;
}
meshBoundsWorld.Encapsulate(v0);
meshBoundsWorld.Encapsulate(v1);
meshBoundsWorld.Encapsulate(v2);
}
int triCount = _allTriangles.Count - firstTri;
if (triCount > 0)
{
_meshes.Add(new CollisionMesh
{
worldAABB = meshBoundsWorld,
firstTriangle = firstTri,
triangleCount = triCount,
roomIndex = 0xFF,
});
}
}
// Build spatial grid
if (_meshes.Count > 0)
{
BuildSpatialGrid(gteScaling);
}
else
{
_chunkGridW = 0;
_chunkGridH = 0;
}
}
private void BuildSpatialGrid(float gteScaling)
{
// Compute world bounds of all collision
Bounds allBounds = _meshes[0].worldAABB;
foreach (var mesh in _meshes)
allBounds.Encapsulate(mesh.worldAABB);
// Grid cell size: ~4 GTE units in world space
_chunkSize = 4.0f * gteScaling;
_chunkOrigin = new Vector3(allBounds.min.x, 0, allBounds.min.z);
_chunkGridW = Mathf.CeilToInt((allBounds.max.x - allBounds.min.x) / _chunkSize);
_chunkGridH = Mathf.CeilToInt((allBounds.max.z - allBounds.min.z) / _chunkSize);
// Clamp to reasonable limits
_chunkGridW = Mathf.Clamp(_chunkGridW, 1, 64);
_chunkGridH = Mathf.Clamp(_chunkGridH, 1, 64);
// For each chunk, find which meshes overlap it
// We store mesh indices sorted per chunk
var chunkMeshLists = new List<int>[_chunkGridW, _chunkGridH];
for (int z = 0; z < _chunkGridH; z++)
for (int x = 0; x < _chunkGridW; x++)
chunkMeshLists[x, z] = new List<int>();
for (int mi = 0; mi < _meshes.Count; mi++)
{
var mesh = _meshes[mi];
int minCX = Mathf.FloorToInt((mesh.worldAABB.min.x - _chunkOrigin.x) / _chunkSize);
int maxCX = Mathf.FloorToInt((mesh.worldAABB.max.x - _chunkOrigin.x) / _chunkSize);
int minCZ = Mathf.FloorToInt((mesh.worldAABB.min.z - _chunkOrigin.z) / _chunkSize);
int maxCZ = Mathf.FloorToInt((mesh.worldAABB.max.z - _chunkOrigin.z) / _chunkSize);
minCX = Mathf.Clamp(minCX, 0, _chunkGridW - 1);
maxCX = Mathf.Clamp(maxCX, 0, _chunkGridW - 1);
minCZ = Mathf.Clamp(minCZ, 0, _chunkGridH - 1);
maxCZ = Mathf.Clamp(maxCZ, 0, _chunkGridH - 1);
for (int cz = minCZ; cz <= maxCZ; cz++)
for (int cx = minCX; cx <= maxCX; cx++)
chunkMeshLists[cx, cz].Add(mi);
}
// Flatten into contiguous array (mesh indices already in order)
// We'll write chunks as (firstMeshIndex, meshCount) referencing the mesh header array
_chunks = new CollisionChunkExport[_chunkGridW, _chunkGridH];
for (int z = 0; z < _chunkGridH; z++)
{
for (int x = 0; x < _chunkGridW; x++)
{
var list = chunkMeshLists[x, z];
_chunks[x, z] = new CollisionChunkExport
{
firstMeshIndex = list.Count > 0 ? list[0] : 0,
meshCount = list.Count,
};
}
}
}
/// <summary>
/// Write collision data to binary.
/// All coordinates converted to PS1 20.12 fixed-point with Y flip.
/// </summary>
public void WriteToBinary(BinaryWriter writer, float gteScaling)
{
// Header (20 bytes)
writer.Write((ushort)_meshes.Count);
writer.Write((ushort)_allTriangles.Count);
writer.Write((ushort)_chunkGridW);
writer.Write((ushort)_chunkGridH);
writer.Write(PSXTrig.ConvertWorldToFixed12(_chunkOrigin.x / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(_chunkOrigin.z / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(_chunkSize / gteScaling));
// Mesh headers (32 bytes each)
foreach (var mesh in _meshes)
{
writer.Write(PSXTrig.ConvertWorldToFixed12(mesh.worldAABB.min.x / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(-mesh.worldAABB.max.y / gteScaling)); // Y flip
writer.Write(PSXTrig.ConvertWorldToFixed12(mesh.worldAABB.min.z / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(mesh.worldAABB.max.x / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(-mesh.worldAABB.min.y / gteScaling)); // Y flip
writer.Write(PSXTrig.ConvertWorldToFixed12(mesh.worldAABB.max.z / gteScaling));
writer.Write((ushort)mesh.firstTriangle);
writer.Write((ushort)mesh.triangleCount);
writer.Write(mesh.roomIndex);
writer.Write((byte)0);
writer.Write((byte)0);
writer.Write((byte)0);
}
// Triangles (52 bytes each)
foreach (var tri in _allTriangles)
{
// v0
writer.Write(PSXTrig.ConvertWorldToFixed12(tri.v0.x / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(-tri.v0.y / gteScaling)); // Y flip
writer.Write(PSXTrig.ConvertWorldToFixed12(tri.v0.z / gteScaling));
// edge1
writer.Write(PSXTrig.ConvertWorldToFixed12(tri.e1.x / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(-tri.e1.y / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(tri.e1.z / gteScaling));
// edge2
writer.Write(PSXTrig.ConvertWorldToFixed12(tri.e2.x / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(-tri.e2.y / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(tri.e2.z / gteScaling));
// normal (in PS1 space: Y negated)
writer.Write(PSXTrig.ConvertWorldToFixed12(tri.normal.x));
writer.Write(PSXTrig.ConvertWorldToFixed12(-tri.normal.y));
writer.Write(PSXTrig.ConvertWorldToFixed12(tri.normal.z));
// flags
writer.Write(tri.flags);
writer.Write(tri.roomIndex);
writer.Write((ushort)0); // pad
}
// Spatial grid chunks (4 bytes each, exterior only)
if (_chunkGridW > 0 && _chunkGridH > 0)
{
for (int z = 0; z < _chunkGridH; z++)
{
for (int x = 0; x < _chunkGridW; x++)
{
writer.Write((ushort)_chunks[x, z].firstMeshIndex);
writer.Write((ushort)_chunks[x, z].meshCount);
}
}
}
}
/// <summary>
/// Get total bytes that will be written.
/// </summary>
public int GetBinarySize()
{
int size = 20; // header
size += _meshes.Count * 32;
size += _allTriangles.Count * 52;
if (_chunkGridW > 0 && _chunkGridH > 0)
size += _chunkGridW * _chunkGridH * 4;
return size;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 701b39be55b3bbb46b1c2a4ddaa34132

View File

@@ -3,9 +3,12 @@ using UnityEngine;
namespace SplashEdit.RuntimeCode namespace SplashEdit.RuntimeCode
{ {
[CreateAssetMenu(fileName = "PSXData", menuName = "Scriptable Objects/PSXData")]
[CreateAssetMenu(fileName = "PSXData", menuName = "PSXSplash/PS1 Project Data")]
public class PSXData : ScriptableObject public class PSXData : ScriptableObject
{ {
// Texture packing settings
public Vector2 OutputResolution = new Vector2(320, 240); public Vector2 OutputResolution = new Vector2(320, 240);
public bool DualBuffering = true; public bool DualBuffering = true;
public bool VerticalBuffering = true; public bool VerticalBuffering = true;

View File

@@ -1,2 +1,2 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: cbd8c66199e036896848ce1569567dd6 guid: b6e1524fb8b4b754e965d03e634658e6

View File

@@ -0,0 +1,61 @@
using UnityEngine;
namespace SplashEdit.RuntimeCode
{
/// <summary>
/// Makes an object interactable by the player.
/// When the player is within range and presses the interact button,
/// the onInteract Lua event fires.
/// </summary>
[RequireComponent(typeof(PSXObjectExporter))]
public class PSXInteractable : MonoBehaviour
{
[Header("Interaction Settings")]
[Tooltip("Distance within which the player can interact with this object")]
[SerializeField] private float interactionRadius = 2.0f;
[Tooltip("Button that triggers interaction (0-15, matches PS1 button mapping)")]
[SerializeField] private int interactButton = 5; // Default to Cross button
[Tooltip("Can this object be interacted with multiple times?")]
[SerializeField] private bool isRepeatable = true;
[Tooltip("Cooldown between interactions (in frames, 60 = 1 second at NTSC)")]
[SerializeField] private ushort cooldownFrames = 30;
[Tooltip("Show interaction prompt when in range (requires UI system)")]
[SerializeField] private bool showPrompt = true;
[Header("Advanced")]
[Tooltip("Require line-of-sight to player for interaction")]
[SerializeField] private bool requireLineOfSight = false;
[Tooltip("Custom interaction point offset from object center")]
[SerializeField] private Vector3 interactionOffset = Vector3.zero;
// Public accessors for export
public float InteractionRadius => interactionRadius;
public int InteractButton => interactButton;
public bool IsRepeatable => isRepeatable;
public ushort CooldownFrames => cooldownFrames;
public bool ShowPrompt => showPrompt;
public bool RequireLineOfSight => requireLineOfSight;
public Vector3 InteractionOffset => interactionOffset;
private void OnDrawGizmosSelected()
{
// Draw interaction radius
Gizmos.color = new Color(1f, 1f, 0f, 0.3f); // Yellow, semi-transparent
Vector3 center = transform.position + interactionOffset;
Gizmos.DrawWireSphere(center, interactionRadius);
// Draw filled sphere with lower alpha
Gizmos.color = new Color(1f, 1f, 0f, 0.1f);
Gizmos.DrawSphere(center, interactionRadius);
// Draw interaction point
Gizmos.color = Color.yellow;
Gizmos.DrawSphere(center, 0.1f);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9b542f4ca31fa6548b8914e96dd0fae2

View File

@@ -1,88 +1,87 @@
using UnityEngine; using UnityEngine;
public static class PSXLightingBaker namespace SplashEdit.RuntimeCode
{ {
/// <summary> public static class PSXLightingBaker
/// Computes the per-vertex lighting from all scene light sources.
/// Incorporates ambient, diffuse, and spotlight falloff.
/// </summary>
/// <param name="vertex">The world-space position of the vertex.</param>
/// <param name="normal">The normalized world-space normal of the vertex.</param>
/// <returns>A Color representing the lit vertex.</returns>
public static Color ComputeLighting(Vector3 vertex, Vector3 normal)
{ {
Color finalColor = Color.black; /// <summary>
/// Computes the per-vertex lighting from all scene light sources.
Light[] lights = Object.FindObjectsByType<Light>(FindObjectsSortMode.None); /// Incorporates ambient, diffuse, and spotlight falloff.
/// </summary>
foreach (Light light in lights) /// <param name="vertex">The world-space position of the vertex.</param>
/// <param name="normal">The normalized world-space normal of the vertex.</param>
/// <param name="sceneLights">Pre-gathered array of enabled scene lights (pass to avoid per-vertex FindObjectsByType).</param>
/// <returns>A Color representing the lit vertex.</returns>
public static Color ComputeLighting(Vector3 vertex, Vector3 normal, Light[] sceneLights)
{ {
if (!light.enabled) Color finalColor = Color.black;
continue;
Color lightContribution = Color.black; foreach (Light light in sceneLights)
if (light.type == LightType.Directional)
{ {
Vector3 lightDir = -light.transform.forward; Color lightContribution = Color.black;
float NdotL = Mathf.Max(0f, Vector3.Dot(normal, lightDir));
lightContribution = light.color * light.intensity * NdotL;
}
else if (light.type == LightType.Point)
{
Vector3 lightDir = light.transform.position - vertex;
float distance = lightDir.magnitude;
lightDir.Normalize();
float NdotL = Mathf.Max(0f, Vector3.Dot(normal, lightDir)); if (light.type == LightType.Directional)
float attenuation = 1.0f / Mathf.Max(distance * distance, 0.0001f);
lightContribution = light.color * light.intensity * NdotL * attenuation;
}
else if (light.type == LightType.Spot)
{
Vector3 L = light.transform.position - vertex;
float distance = L.magnitude;
L = L / distance;
float NdotL = Mathf.Max(0f, Vector3.Dot(normal, L));
float attenuation = 1.0f / Mathf.Max(distance * distance, 0.0001f);
float outerAngleRad = (light.spotAngle * 0.5f) * Mathf.Deg2Rad;
float innerAngleRad = outerAngleRad * 0.8f;
if (light is Light spotLight)
{ {
if (spotLight.innerSpotAngle > 0) Vector3 lightDir = -light.transform.forward;
float NdotL = Mathf.Max(0f, Vector3.Dot(normal, lightDir));
lightContribution = light.color * light.intensity * NdotL;
}
else if (light.type == LightType.Point)
{
Vector3 lightDir = light.transform.position - vertex;
float distance = lightDir.magnitude;
lightDir.Normalize();
float NdotL = Mathf.Max(0f, Vector3.Dot(normal, lightDir));
float attenuation = 1.0f / Mathf.Max(distance * distance, 0.0001f);
lightContribution = light.color * light.intensity * NdotL * attenuation;
}
else if (light.type == LightType.Spot)
{
Vector3 L = light.transform.position - vertex;
float distance = L.magnitude;
L = L / distance;
float NdotL = Mathf.Max(0f, Vector3.Dot(normal, L));
float attenuation = 1.0f / Mathf.Max(distance * distance, 0.0001f);
float outerAngleRad = (light.spotAngle * 0.5f) * Mathf.Deg2Rad;
float innerAngleRad = outerAngleRad * 0.8f;
if (light is Light spotLight)
{ {
innerAngleRad = (spotLight.innerSpotAngle * 0.5f) * Mathf.Deg2Rad; if (spotLight.innerSpotAngle > 0)
{
innerAngleRad = (spotLight.innerSpotAngle * 0.5f) * Mathf.Deg2Rad;
}
}
float cosOuter = Mathf.Cos(outerAngleRad);
float cosInner = Mathf.Cos(innerAngleRad);
float cosAngle = Vector3.Dot(L, -light.transform.forward);
if (cosAngle >= cosOuter)
{
float spotFactor = Mathf.Clamp01((cosAngle - cosOuter) / (cosInner - cosOuter));
spotFactor = Mathf.Pow(spotFactor, 4.0f);
lightContribution = light.color * light.intensity * NdotL * attenuation * spotFactor;
}
else
{
lightContribution = Color.black;
} }
} }
float cosOuter = Mathf.Cos(outerAngleRad); finalColor += lightContribution;
float cosInner = Mathf.Cos(innerAngleRad);
float cosAngle = Vector3.Dot(L, -light.transform.forward);
if (cosAngle >= cosOuter)
{
float spotFactor = Mathf.Clamp01((cosAngle - cosOuter) / (cosInner - cosOuter));
spotFactor = Mathf.Pow(spotFactor, 4.0f);
lightContribution = light.color * light.intensity * NdotL * attenuation * spotFactor;
}
else
{
lightContribution = Color.black;
}
} }
finalColor += lightContribution; finalColor.r = Mathf.Clamp(finalColor.r, 0.0f, 0.8f);
finalColor.g = Mathf.Clamp(finalColor.g, 0.0f, 0.8f);
finalColor.b = Mathf.Clamp(finalColor.b, 0.0f, 0.8f);
finalColor.a = 1f;
return finalColor;
} }
finalColor.r = Mathf.Clamp(finalColor.r, 0.0f, 0.8f);
finalColor.g = Mathf.Clamp(finalColor.g, 0.0f, 0.8f);
finalColor.b = Mathf.Clamp(finalColor.b, 0.0f, 0.8f);
finalColor.a = 1f;
return finalColor;
} }
} }

View File

@@ -1,2 +1,2 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: b707b7d499862621fb6c82aba4caa183 guid: 15a0e6c8af6d78e46bb65ef21c3f75fb

View File

@@ -28,8 +28,17 @@ namespace SplashEdit.RuntimeCode
public PSXVertex v1; public PSXVertex v1;
public PSXVertex v2; public PSXVertex v2;
public PSXTexture2D Texture; /// <summary>
public readonly PSXVertex[] Vertexes => new PSXVertex[] { v0, v1, v2 }; /// Index into the texture list for this triangle's material.
/// -1 means untextured (vertex-color only, rendered as POLY_G3).
/// </summary>
public int TextureIndex;
/// <summary>
/// Whether this triangle is untextured (vertex-color only).
/// Untextured triangles are rendered as GouraudTriangle (POLY_G3) on PS1.
/// </summary>
public bool IsUntextured => TextureIndex == -1;
} }
/// <summary> /// <summary>
@@ -77,63 +86,93 @@ namespace SplashEdit.RuntimeCode
/// <summary> /// <summary>
/// Creates a PSXMesh from a Unity Mesh by converting its vertices, normals, UVs, and applying shading. /// Creates a PSXMesh from a Unity Mesh by converting its vertices, normals, UVs, and applying shading.
/// </summary> /// </summary>
/// <param name="mesh">The Unity mesh to convert.</param> /// <summary>
/// <param name="textureWidth">Width of the texture (default is 256).</param> /// Creates a PSXMesh from a Unity Renderer by extracting its mesh and materials.
/// <param name="textureHeight">Height of the texture (default is 256).</param> /// </summary>
/// <param name="transform">Optional transform to convert vertices to world space.</param>
/// <returns>A new PSXMesh containing the converted triangles.</returns>
public static PSXMesh CreateFromUnityRenderer(Renderer renderer, float GTEScaling, Transform transform, List<PSXTexture2D> textures) public static PSXMesh CreateFromUnityRenderer(Renderer renderer, float GTEScaling, Transform transform, List<PSXTexture2D> textures)
{ {
PSXMesh psxMesh = new PSXMesh { Triangles = new List<Tri>() };
// Get materials and mesh.
Material[] materials = renderer.sharedMaterials;
Mesh mesh = renderer.GetComponent<MeshFilter>().sharedMesh; Mesh mesh = renderer.GetComponent<MeshFilter>().sharedMesh;
return BuildFromMesh(mesh, renderer, GTEScaling, transform, textures);
}
// Iterate over each submesh. /// <summary>
for (int submeshIndex = 0; submeshIndex < materials.Length; submeshIndex++) /// Creates a PSXMesh from a supplied Unity Mesh with the renderer's materials.
/// </summary>
public static PSXMesh CreateFromUnityMesh(Mesh mesh, Renderer renderer, float GTEScaling, Transform transform, List<PSXTexture2D> textures)
{
return BuildFromMesh(mesh, renderer, GTEScaling, transform, textures);
}
private static PSXMesh BuildFromMesh(Mesh mesh, Renderer renderer, float GTEScaling, Transform transform, List<PSXTexture2D> textures)
{
PSXMesh psxMesh = new PSXMesh { Triangles = new List<Tri>() };
Material[] materials = renderer.sharedMaterials;
// Guard: only recalculate normals if missing
if (mesh.normals == null || mesh.normals.Length == 0)
mesh.RecalculateNormals();
if (mesh.uv == null || mesh.uv.Length == 0)
mesh.uv = new Vector2[mesh.vertices.Length];
Vector3[] smoothNormals = RecalculateSmoothNormals(mesh);
// Cache lights once for the entire mesh
Light[] sceneLights = Object.FindObjectsByType<Light>(FindObjectsSortMode.None)
.Where(l => l.enabled).ToArray();
// Precompute world positions and normals for all vertices
Vector3[] worldVertices = new Vector3[mesh.vertices.Length];
Vector3[] worldNormals = new Vector3[mesh.normals.Length];
for (int i = 0; i < mesh.vertices.Length; i++)
{ {
// Get the triangles for this submesh. worldVertices[i] = transform.TransformPoint(mesh.vertices[i]);
int[] submeshTriangles = mesh.GetTriangles(submeshIndex); worldNormals[i] = transform.TransformDirection(smoothNormals[i]).normalized;
}
// Get the material for this submesh. for (int submeshIndex = 0; submeshIndex < mesh.subMeshCount; submeshIndex++)
Material material = materials[submeshIndex]; {
int materialIndex = Mathf.Min(submeshIndex, materials.Length - 1);
// Get the corresponding texture for this material (assume mainTexture). Material material = materials[materialIndex];
Texture2D texture = material.mainTexture as Texture2D; Texture2D texture = material != null ? material.mainTexture as Texture2D : null;
PSXTexture2D psxTexture = null;
int textureIndex = -1;
if (texture != null) if (texture != null)
{ {
// Find the corresponding PSX texture based on the Unity texture. for (int i = 0; i < textures.Count; i++)
psxTexture = textures.FirstOrDefault(t => t.OriginalTexture == texture); {
if (textures[i].OriginalTexture == texture)
{
textureIndex = i;
break;
}
}
} }
if (psxTexture == null) int[] submeshTriangles = mesh.GetTriangles(submeshIndex);
{
continue;
}
// Get mesh data arrays.
mesh.RecalculateNormals();
Vector3[] vertices = mesh.vertices; Vector3[] vertices = mesh.vertices;
Vector3[] normals = mesh.normals; Vector3[] normals = mesh.normals;
Vector3[] smoothNormals = RecalculateSmoothNormals(mesh);
Vector2[] uv = mesh.uv; Vector2[] uv = mesh.uv;
PSXVertex convertData(int index) PSXVertex convertData(int index)
{ {
// Scale the vertex based on world scale.
Vector3 v = Vector3.Scale(vertices[index], transform.lossyScale); Vector3 v = Vector3.Scale(vertices[index], transform.lossyScale);
// Transform the vertex to world space. Vector3 wv = worldVertices[index];
Vector3 wv = transform.TransformPoint(vertices[index]); Vector3 wn = worldNormals[index];
// Transform the normals to world space. Color c = PSXLightingBaker.ComputeLighting(wv, wn, sceneLights);
Vector3 wn = transform.TransformDirection(smoothNormals[index]).normalized;
// Compute lighting for each vertex. if (textureIndex == -1)
Color c = PSXLightingBaker.ComputeLighting(wv, wn); {
// Convert vertex to PSX format, including fixed-point conversion and shading. Color matColor = material != null && material.HasProperty("_Color")
return ConvertToPSXVertex(v, GTEScaling, normals[index], uv[index], psxTexture?.Width, psxTexture?.Height, c); ? material.color : Color.white;
c = new Color(c.r * matColor.r, c.g * matColor.g, c.b * matColor.b);
return ConvertToPSXVertex(v, GTEScaling, normals[index], Vector2.zero, null, null, c);
}
return ConvertToPSXVertex(v, GTEScaling, normals[index], uv[index],
textures[textureIndex]?.Width, textures[textureIndex]?.Height, c);
} }
// Iterate through the triangles of the submesh.
for (int i = 0; i < submeshTriangles.Length; i += 3) for (int i = 0; i < submeshTriangles.Length; i += 3)
{ {
int vid0 = submeshTriangles[i]; int vid0 = submeshTriangles[i];
@@ -147,8 +186,13 @@ namespace SplashEdit.RuntimeCode
(vid1, vid2) = (vid2, vid1); (vid1, vid2) = (vid2, vid1);
} }
// Add the constructed triangle to the mesh. psxMesh.Triangles.Add(new Tri
psxMesh.Triangles.Add(new Tri { v0 = convertData(vid0), v1 = convertData(vid1), v2 = convertData(vid2), Texture = psxTexture }); {
v0 = convertData(vid0),
v1 = convertData(vid1),
v2 = convertData(vid2),
TextureIndex = textureIndex
});
} }
} }

View File

@@ -1,2 +1,2 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 9025daa0c62549ee29d968f86c69eec9 guid: 0bde77749a0264146a4ead39946dce2f

View File

@@ -1,95 +0,0 @@
using UnityEngine;
using Unity.AI.Navigation;
using UnityEngine.AI;
using System.Collections.Generic;
namespace SplashEdit.RuntimeCode
{
public struct PSXNavMeshTri
{
public PSXNavmeshVertex v0, v1, v2;
}
public struct PSXNavmeshVertex
{
public short vx, vy, vz;
}
[RequireComponent(typeof(NavMeshSurface))]
public class PSXNavMesh : MonoBehaviour
{
Mesh mesh;
[HideInInspector]
public List<PSXNavMeshTri> Navmesh { get; set; }
public void CreateNavmesh(float GTEScaling)
{
mesh = new Mesh();
Navmesh = new List<PSXNavMeshTri>();
NavMeshSurface navMeshSurface = GetComponent<NavMeshSurface>();
navMeshSurface.overrideTileSize = true;
navMeshSurface.tileSize = 16;
navMeshSurface.overrideVoxelSize = true;
navMeshSurface.voxelSize = 0.1f;
navMeshSurface.BuildNavMesh();
NavMeshTriangulation triangulation = NavMesh.CalculateTriangulation();
navMeshSurface.overrideTileSize = false;
navMeshSurface.overrideVoxelSize = false;
int[] triangles = triangulation.indices;
Vector3[] vertices = triangulation.vertices;
mesh.vertices = vertices;
mesh.triangles = triangles;
mesh.RecalculateNormals();
for (int i = 0; i < triangles.Length; i += 3)
{
int vid0 = triangles[i];
int vid1 = triangles[i + 1];
int vid2 = triangles[i + 2];
PSXNavMeshTri tri = new PSXNavMeshTri();
tri.v0.vx = PSXTrig.ConvertCoordinateToPSX(vertices[vid0].x, GTEScaling);
tri.v0.vy = PSXTrig.ConvertCoordinateToPSX(-vertices[vid0].y, GTEScaling);
tri.v0.vz = PSXTrig.ConvertCoordinateToPSX(vertices[vid0].z, GTEScaling);
tri.v1.vx = PSXTrig.ConvertCoordinateToPSX(vertices[vid1].x, GTEScaling);
tri.v1.vy = PSXTrig.ConvertCoordinateToPSX(-vertices[vid1].y, GTEScaling);
tri.v1.vz = PSXTrig.ConvertCoordinateToPSX(vertices[vid1].z, GTEScaling);
tri.v2.vx = PSXTrig.ConvertCoordinateToPSX(vertices[vid2].x, GTEScaling);
tri.v2.vy = PSXTrig.ConvertCoordinateToPSX(-vertices[vid2].y, GTEScaling);
tri.v2.vz = PSXTrig.ConvertCoordinateToPSX(vertices[vid2].z, GTEScaling);
Navmesh.Add(tri);
}
}
public void OnDrawGizmos()
{
if (mesh == null) return;
Gizmos.DrawMesh(mesh);
Gizmos.color = Color.green;
var vertices = mesh.vertices;
var triangles = mesh.triangles;
for (int i = 0; i < triangles.Length; i += 3)
{
Vector3 v0 = vertices[triangles[i]];
Vector3 v1 = vertices[triangles[i + 1]];
Vector3 v2 = vertices[triangles[i + 2]];
Gizmos.DrawLine(v0, v1);
Gizmos.DrawLine(v1, v2);
Gizmos.DrawLine(v2, v0);
}
}
}
}

View File

@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 6a2f8d45e1591de1e945b3b7bdfb123b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: d695ef52da250cdcea6c30ab1122c56e, type: 3}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,522 @@
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using DotRecast.Core;
using DotRecast.Core.Numerics;
using DotRecast.Recast;
namespace SplashEdit.RuntimeCode
{
public enum NavSurfaceType : byte { Flat = 0, Ramp = 1, Stairs = 2 }
/// <summary>
/// PS1 nav mesh builder using DotRecast (C# port of Recast).
/// Runs the full Recast voxelization pipeline on scene geometry,
/// then extracts convex polygons with accurate heights from the detail mesh.
/// </summary>
public class PSXNavRegionBuilder
{
public float AgentHeight = 1.8f;
public float AgentRadius = 0.3f;
public float MaxStepHeight = 0.35f;
public float WalkableSlopeAngle = 46.0f;
public float CellSize = 0.05f;
public float CellHeight = 0.025f;
public float MergePlaneError = 0.1f;
public const int MaxVertsPerRegion = 8;
private List<NavRegionExport> _regions = new();
private List<NavPortalExport> _portals = new();
private int _startRegion;
public int RegionCount => _regions.Count;
public int PortalCount => _portals.Count;
public IReadOnlyList<NavRegionExport> Regions => _regions;
public IReadOnlyList<NavPortalExport> Portals => _portals;
public VoxelCell[] DebugCells => null;
public int DebugGridW => 0;
public int DebugGridH => 0;
public float DebugOriginX => 0;
public float DebugOriginZ => 0;
public float DebugVoxelSize => 0;
public class NavRegionExport
{
public List<Vector2> vertsXZ = new();
public float planeA, planeB, planeD;
public int portalStart, portalCount;
public NavSurfaceType surfaceType;
public byte roomIndex;
public Plane floorPlane;
public List<Vector3> worldTris = new();
public List<int> sourceTriIndices = new();
}
public struct NavPortalExport
{
public Vector2 a, b;
public int neighborRegion;
public float heightDelta;
}
public struct VoxelCell
{
public float worldY, slopeAngle;
public bool occupied, blocked;
public int regionId;
}
/// <summary>PSXRoom volumes for spatial room assignment. Set before Build().</summary>
public PSXRoom[] PSXRooms { get; set; }
public void Build(PSXObjectExporter[] exporters, Vector3 spawn)
{
_regions.Clear(); _portals.Clear(); _startRegion = 0;
// 1. Collect world-space geometry from all exporters
var allVerts = new List<float>();
var allTris = new List<int>();
CollectGeometry(exporters, allVerts, allTris);
if (allVerts.Count < 9 || allTris.Count < 3)
{
Debug.LogWarning("[Nav] No geometry to build navmesh from.");
return;
}
float[] verts = allVerts.ToArray();
int[] tris = allTris.ToArray();
int nverts = allVerts.Count / 3;
int ntris = allTris.Count / 3;
// 2. Recast parameters (convert to voxel units)
float cs = CellSize;
float ch = CellHeight;
int walkableHeight = (int)Math.Ceiling(AgentHeight / ch);
int walkableClimb = (int)Math.Floor(MaxStepHeight / ch);
int walkableRadius = (int)Math.Ceiling(AgentRadius / cs);
int maxEdgeLen = (int)(12.0f / cs);
float maxSimplificationError = 1.3f;
int minRegionArea = 8;
int mergeRegionArea = 20;
int maxVertsPerPoly = 6;
float detailSampleDist = cs * 6;
float detailSampleMaxError = ch * 1;
// 3. Compute bounds with border padding
float bminX = float.MaxValue, bminY = float.MaxValue, bminZ = float.MaxValue;
float bmaxX = float.MinValue, bmaxY = float.MinValue, bmaxZ = float.MinValue;
for (int i = 0; i < verts.Length; i += 3)
{
bminX = Math.Min(bminX, verts[i]); bmaxX = Math.Max(bmaxX, verts[i]);
bminY = Math.Min(bminY, verts[i+1]); bmaxY = Math.Max(bmaxY, verts[i+1]);
bminZ = Math.Min(bminZ, verts[i+2]); bmaxZ = Math.Max(bmaxZ, verts[i+2]);
}
float borderPad = walkableRadius * cs;
bminX -= borderPad; bminZ -= borderPad;
bmaxX += borderPad; bmaxZ += borderPad;
var bmin = new RcVec3f(bminX, bminY, bminZ);
var bmax = new RcVec3f(bmaxX, bmaxY, bmaxZ);
int gw = (int)((bmaxX - bminX) / cs + 0.5f);
int gh = (int)((bmaxZ - bminZ) / cs + 0.5f);
// 4. Run Recast pipeline
var ctx = new RcContext();
// Create heightfield
var solid = new RcHeightfield(gw, gh, bmin, bmax, cs, ch, 0);
// Mark walkable triangles
int[] areas = RcRecast.MarkWalkableTriangles(ctx, WalkableSlopeAngle, verts, tris, ntris,
new RcAreaModification(RcRecast.RC_WALKABLE_AREA));
// Rasterize
RcRasterizations.RasterizeTriangles(ctx, verts, tris, areas, ntris, solid, walkableClimb);
// Filter
RcFilters.FilterLowHangingWalkableObstacles(ctx, walkableClimb, solid);
RcFilters.FilterLedgeSpans(ctx, walkableHeight, walkableClimb, solid);
RcFilters.FilterWalkableLowHeightSpans(ctx, walkableHeight, solid);
// Build compact heightfield
var chf = RcCompacts.BuildCompactHeightfield(ctx, walkableHeight, walkableClimb, solid);
// Erode walkable area
RcAreas.ErodeWalkableArea(ctx, walkableRadius, chf);
// Build distance field and regions
RcRegions.BuildDistanceField(ctx, chf);
RcRegions.BuildRegions(ctx, chf, minRegionArea, mergeRegionArea);
// Build contours
var cset = RcContours.BuildContours(ctx, chf, maxSimplificationError, maxEdgeLen,
(int)RcBuildContoursFlags.RC_CONTOUR_TESS_WALL_EDGES);
// Build polygon mesh
var pmesh = RcMeshs.BuildPolyMesh(ctx, cset, maxVertsPerPoly);
// Build detail mesh for accurate heights
var dmesh = RcMeshDetails.BuildPolyMeshDetail(ctx, pmesh, chf, detailSampleDist, detailSampleMaxError);
// 5. Extract polygons as NavRegions
int nvp = pmesh.nvp;
int RC_MESH_NULL_IDX = 0xffff;
for (int i = 0; i < pmesh.npolys; i++)
{
// Count valid vertices in this polygon
int nv = 0;
for (int j = 0; j < nvp; j++)
{
if (pmesh.polys[i * 2 * nvp + j] == RC_MESH_NULL_IDX) break;
nv++;
}
if (nv < 3) continue;
var region = new NavRegionExport();
var pts3d = new List<Vector3>();
for (int j = 0; j < nv; j++)
{
int vi = pmesh.polys[i * 2 * nvp + j];
// Get XZ from poly mesh (cell coords -> world)
float wx = pmesh.bmin.X + pmesh.verts[vi * 3 + 0] * pmesh.cs;
float wz = pmesh.bmin.Z + pmesh.verts[vi * 3 + 2] * pmesh.cs;
// Get accurate Y from detail mesh
float wy;
if (dmesh != null && i < dmesh.nmeshes)
{
int vbase = dmesh.meshes[i * 4 + 0];
// Detail mesh stores polygon verts first, in order
wy = dmesh.verts[(vbase + j) * 3 + 1];
}
else
{
// Fallback: coarse Y from poly mesh
wy = pmesh.bmin.Y + pmesh.verts[vi * 3 + 1] * pmesh.ch;
}
region.vertsXZ.Add(new Vector2(wx, wz));
pts3d.Add(new Vector3(wx, wy, wz));
}
// Ensure CCW winding
float signedArea = 0;
for (int j = 0; j < region.vertsXZ.Count; j++)
{
var a = region.vertsXZ[j];
var b = region.vertsXZ[(j + 1) % region.vertsXZ.Count];
signedArea += a.x * b.y - b.x * a.y;
}
if (signedArea < 0)
{
region.vertsXZ.Reverse();
pts3d.Reverse();
}
FitPlane(region, pts3d);
_regions.Add(region);
}
// 6. Build portals from Recast neighbor connectivity
var perRegion = new Dictionary<int, List<NavPortalExport>>();
for (int i = 0; i < _regions.Count; i++)
perRegion[i] = new List<NavPortalExport>();
// Build mapping: pmesh poly index -> region index
// (some polys may be skipped if nv < 3, so we need this mapping)
var polyToRegion = new Dictionary<int, int>();
int regionIdx = 0;
for (int i = 0; i < pmesh.npolys; i++)
{
int nv = 0;
for (int j = 0; j < nvp; j++)
{
if (pmesh.polys[i * 2 * nvp + j] == RC_MESH_NULL_IDX) break;
nv++;
}
if (nv < 3) continue;
polyToRegion[i] = regionIdx++;
}
for (int i = 0; i < pmesh.npolys; i++)
{
if (!polyToRegion.TryGetValue(i, out int srcRegion)) continue;
int nv = 0;
for (int j = 0; j < nvp; j++)
{
if (pmesh.polys[i * 2 * nvp + j] == RC_MESH_NULL_IDX) break;
nv++;
}
for (int j = 0; j < nv; j++)
{
int neighbor = pmesh.polys[i * 2 * nvp + nvp + j];
if (neighbor == RC_MESH_NULL_IDX || (neighbor & 0x8000) != 0) continue;
if (!polyToRegion.TryGetValue(neighbor, out int dstRegion)) continue;
// Portal edge vertices from pmesh directly (NOT from region,
// which may have been reversed for CCW winding)
int vi0 = pmesh.polys[i * 2 * nvp + j];
int vi1 = pmesh.polys[i * 2 * nvp + (j + 1) % nv];
float ax = pmesh.bmin.X + pmesh.verts[vi0 * 3 + 0] * pmesh.cs;
float az = pmesh.bmin.Z + pmesh.verts[vi0 * 3 + 2] * pmesh.cs;
float bx = pmesh.bmin.X + pmesh.verts[vi1 * 3 + 0] * pmesh.cs;
float bz = pmesh.bmin.Z + pmesh.verts[vi1 * 3 + 2] * pmesh.cs;
var a2 = new Vector2(ax, az);
var b2 = new Vector2(bx, bz);
// Height delta at midpoint of portal edge
var mid = new Vector2((ax + bx) / 2, (az + bz) / 2);
float yHere = EvalY(_regions[srcRegion], mid);
float yThere = EvalY(_regions[dstRegion], mid);
perRegion[srcRegion].Add(new NavPortalExport
{
a = a2,
b = b2,
neighborRegion = dstRegion,
heightDelta = yThere - yHere
});
}
}
// Assign portals
foreach (var kvp in perRegion)
{
_regions[kvp.Key].portalStart = _portals.Count;
_regions[kvp.Key].portalCount = kvp.Value.Count;
_portals.AddRange(kvp.Value);
}
// 7. Assign rooms: spatial containment if PSXRooms provided, BFS fallback
if (PSXRooms != null && PSXRooms.Length > 0)
AssignRoomsFromPSXRooms(PSXRooms);
else
AssignRoomsByBFS();
// 8. Find start region closest to spawn
_startRegion = FindClosestRegion(spawn);
}
void CollectGeometry(PSXObjectExporter[] exporters, List<float> outVerts, List<int> outTris)
{
foreach (var exporter in exporters)
{
MeshFilter mf = exporter.GetComponent<MeshFilter>();
Mesh mesh = mf?.sharedMesh;
if (mesh == null) continue;
Matrix4x4 worldMatrix = exporter.transform.localToWorldMatrix;
Vector3[] vertices = mesh.vertices;
int[] indices = mesh.triangles;
int baseVert = outVerts.Count / 3;
foreach (var v in vertices)
{
Vector3 w = worldMatrix.MultiplyPoint3x4(v);
outVerts.Add(w.x);
outVerts.Add(w.y);
outVerts.Add(w.z);
}
// Filter triangles: reject downward-facing normals
// (ceilings, roofs, undersides) which should never be walkable.
for (int i = 0; i < indices.Length; i += 3)
{
Vector3 v0 = worldMatrix.MultiplyPoint3x4(vertices[indices[i]]);
Vector3 v1 = worldMatrix.MultiplyPoint3x4(vertices[indices[i + 1]]);
Vector3 v2 = worldMatrix.MultiplyPoint3x4(vertices[indices[i + 2]]);
Vector3 normal = Vector3.Cross(v1 - v0, v2 - v0);
// Skip triangles whose world-space normal points downward (y < 0)
// This eliminates ceilings/roofs that Recast might incorrectly voxelize
if (normal.y < 0f) continue;
outTris.Add(indices[i] + baseVert);
outTris.Add(indices[i + 1] + baseVert);
outTris.Add(indices[i + 2] + baseVert);
}
}
}
int FindClosestRegion(Vector3 spawn)
{
int best = 0;
float bestDist = float.MaxValue;
for (int i = 0; i < _regions.Count; i++)
{
var r = _regions[i];
// Compute centroid
float cx = 0, cz = 0;
foreach (var v in r.vertsXZ) { cx += v.x; cz += v.y; }
cx /= r.vertsXZ.Count; cz /= r.vertsXZ.Count;
float cy = EvalY(r, new Vector2(cx, cz));
float dx = spawn.x - cx, dy = spawn.y - cy, dz = spawn.z - cz;
float dist = dx * dx + dy * dy + dz * dz;
if (dist < bestDist) { bestDist = dist; best = i; }
}
return best;
}
float EvalY(NavRegionExport r, Vector2 xz) => r.planeA * xz.x + r.planeB * xz.y + r.planeD;
void FitPlane(NavRegionExport r, List<Vector3> pts)
{
int n = pts.Count;
if (n < 3) { r.planeA = 0; r.planeB = 0; r.planeD = n > 0 ? pts[0].y : 0; r.surfaceType = NavSurfaceType.Flat; return; }
if (n == 3)
{
// Exact 3-point solve: Y = A*X + B*Z + D
double x0 = pts[0].x, z0 = pts[0].z, y0 = pts[0].y;
double x1 = pts[1].x, z1 = pts[1].z, y1 = pts[1].y;
double x2 = pts[2].x, z2 = pts[2].z, y2 = pts[2].y;
double det = (x0 - x2) * (z1 - z2) - (x1 - x2) * (z0 - z2);
if (Math.Abs(det) < 1e-12) { r.planeA = 0; r.planeB = 0; r.planeD = (float)((y0 + y1 + y2) / 3); }
else
{
double inv = 1.0 / det;
r.planeA = (float)(((y0 - y2) * (z1 - z2) - (y1 - y2) * (z0 - z2)) * inv);
r.planeB = (float)(((x0 - x2) * (y1 - y2) - (x1 - x2) * (y0 - y2)) * inv);
r.planeD = (float)(y0 - r.planeA * x0 - r.planeB * z0);
}
}
else
{
// Least-squares: Y = A*X + B*Z + D
double sX = 0, sZ = 0, sY = 0, sXX = 0, sXZ = 0, sZZ = 0, sXY = 0, sZY = 0;
foreach (var p in pts) { sX += p.x; sZ += p.z; sY += p.y; sXX += p.x * p.x; sXZ += p.x * p.z; sZZ += p.z * p.z; sXY += p.x * p.y; sZY += p.z * p.y; }
double det = sXX * (sZZ * n - sZ * sZ) - sXZ * (sXZ * n - sZ * sX) + sX * (sXZ * sZ - sZZ * sX);
if (Math.Abs(det) < 1e-12) { r.planeA = 0; r.planeB = 0; r.planeD = (float)(sY / n); }
else
{
double inv = 1.0 / det;
r.planeA = (float)((sXY * (sZZ * n - sZ * sZ) - sXZ * (sZY * n - sZ * sY) + sX * (sZY * sZ - sZZ * sY)) * inv);
r.planeB = (float)((sXX * (sZY * n - sZ * sY) - sXY * (sXZ * n - sZ * sX) + sX * (sXZ * sY - sZY * sX)) * inv);
r.planeD = (float)((sXX * (sZZ * sY - sZ * sZY) - sXZ * (sXZ * sY - sZY * sX) + sXY * (sXZ * sZ - sZZ * sX)) * inv);
}
}
float slope = Mathf.Atan(Mathf.Sqrt(r.planeA * r.planeA + r.planeB * r.planeB)) * Mathf.Rad2Deg;
r.surfaceType = slope < 3f ? NavSurfaceType.Flat : slope < 25f ? NavSurfaceType.Ramp : NavSurfaceType.Stairs;
}
/// <summary>
/// Assign room indices to nav regions using PSXRoom spatial containment.
/// Each region's centroid is tested against all PSXRoom volumes. The smallest
/// containing room wins (most specific). Regions outside all rooms get 0xFF.
/// This ensures nav region room indices match the PSXRoomBuilder room indices
/// used by the rendering portal system.
/// </summary>
void AssignRoomsFromPSXRooms(PSXRoom[] psxRooms)
{
const float MARGIN = 0.5f;
Bounds[] roomBounds = new Bounds[psxRooms.Length];
for (int r = 0; r < psxRooms.Length; r++)
{
roomBounds[r] = psxRooms[r].GetWorldBounds();
roomBounds[r].Expand(MARGIN * 2f);
}
for (int i = 0; i < _regions.Count; i++)
{
var reg = _regions[i];
// Compute region centroid from polygon vertices
float cx = 0, cz = 0;
foreach (var v in reg.vertsXZ) { cx += v.x; cz += v.y; }
cx /= reg.vertsXZ.Count; cz /= reg.vertsXZ.Count;
float cy = EvalY(reg, new Vector2(cx, cz));
Vector3 centroid = new Vector3(cx, cy, cz);
byte bestRoom = 0xFF;
float bestVolume = float.MaxValue;
for (int r = 0; r < psxRooms.Length; r++)
{
if (roomBounds[r].Contains(centroid))
{
float vol = roomBounds[r].size.x * roomBounds[r].size.y * roomBounds[r].size.z;
if (vol < bestVolume)
{
bestVolume = vol;
bestRoom = (byte)r;
}
}
}
reg.roomIndex = bestRoom;
}
}
/// <summary>
/// Fallback room assignment via BFS over nav portal connectivity.
/// Used when no PSXRoom volumes exist (exterior scenes).
/// </summary>
void AssignRoomsByBFS()
{
byte room = 0;
var vis = new bool[_regions.Count];
for (int i = 0; i < _regions.Count; i++)
{
if (vis[i]) continue;
byte rm = room++;
var q = new Queue<int>(); q.Enqueue(i); vis[i] = true;
while (q.Count > 0)
{
int ri = q.Dequeue(); _regions[ri].roomIndex = rm;
for (int p = _regions[ri].portalStart; p < _regions[ri].portalStart + _regions[ri].portalCount; p++)
{
int nb = _portals[p].neighborRegion;
if (nb >= 0 && nb < _regions.Count && !vis[nb]) { vis[nb] = true; q.Enqueue(nb); }
}
}
}
}
public void WriteToBinary(BinaryWriter writer, float gteScaling)
{
writer.Write((ushort)_regions.Count);
writer.Write((ushort)_portals.Count);
writer.Write((ushort)_startRegion);
writer.Write((ushort)0);
foreach (var r in _regions)
{
for (int v = 0; v < MaxVertsPerRegion; v++)
writer.Write(v < r.vertsXZ.Count ? PSXTrig.ConvertWorldToFixed12(r.vertsXZ[v].x / gteScaling) : 0);
for (int v = 0; v < MaxVertsPerRegion; v++)
writer.Write(v < r.vertsXZ.Count ? PSXTrig.ConvertWorldToFixed12(r.vertsXZ[v].y / gteScaling) : 0);
writer.Write(PSXTrig.ConvertWorldToFixed12(-r.planeA));
writer.Write(PSXTrig.ConvertWorldToFixed12(-r.planeB));
writer.Write(PSXTrig.ConvertWorldToFixed12(-r.planeD / gteScaling));
writer.Write((ushort)r.portalStart);
writer.Write((byte)r.portalCount);
writer.Write((byte)r.vertsXZ.Count);
writer.Write((byte)r.surfaceType);
writer.Write(r.roomIndex);
writer.Write((byte)0);
writer.Write((byte)0);
}
foreach (var p in _portals)
{
writer.Write(PSXTrig.ConvertWorldToFixed12(p.a.x / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(p.a.y / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(p.b.x / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(p.b.y / gteScaling));
writer.Write((ushort)p.neighborRegion);
writer.Write((short)PSXTrig.ConvertToFixed12(p.heightDelta / gteScaling));
}
}
public int GetBinarySize() => 8 + _regions.Count * 84 + _portals.Count * 20;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7446b9ee150d0994fb534c61cd894d6c

View File

@@ -1,25 +1,86 @@
using System.Collections.Generic; using System.Collections.Generic;
using Splashedit.RuntimeCode; using SplashEdit.RuntimeCode;
using UnityEngine; using UnityEngine;
using UnityEngine.Serialization; using UnityEngine.Serialization;
namespace SplashEdit.RuntimeCode namespace SplashEdit.RuntimeCode
{ {
/// <summary>
/// Collision type for PS1 runtime
/// </summary>
public enum PSXCollisionType
{
None = 0, // No collision
Solid = 1, // Solid collision - blocks movement
Trigger = 2, // Trigger - fires events but doesn't block
Platform = 3 // Platform - solid from above, passable from below
}
/// <summary>
/// Object behavior flags for PS1 runtime
/// </summary>
[System.Flags]
public enum PSXObjectFlags
{
None = 0,
Static = 1 << 0, // Object never moves (can be optimized)
Dynamic = 1 << 1, // Object can move
Visible = 1 << 2, // Object is rendered
CastsShadow = 1 << 3, // Object casts shadows (future)
ReceivesShadow = 1 << 4, // Object receives shadows (future)
Interactable = 1 << 5, // Player can interact with this
AlwaysRender = 1 << 6, // Skip frustum culling for this object
}
[RequireComponent(typeof(Renderer))] [RequireComponent(typeof(Renderer))]
public class PSXObjectExporter : MonoBehaviour public class PSXObjectExporter : MonoBehaviour, IPSXExportable
{ {
public LuaFile LuaFile => luaFile; public LuaFile LuaFile => luaFile;
public List<PSXTexture2D> Textures { get; set; } = new List<PSXTexture2D>(); // Stores the converted PlayStation-style texture [FormerlySerializedAs("IsActive")]
public PSXMesh Mesh { get; protected set; } // Stores the converted PlayStation-style mesh [SerializeField] private bool isActive = true;
public bool IsActive => isActive;
public List<PSXTexture2D> Textures { get; set; } = new List<PSXTexture2D>();
public PSXMesh Mesh { get; protected set; }
[Header("Export Settings")] [Header("Export Settings")]
[FormerlySerializedAs("BitDepth")] [FormerlySerializedAs("BitDepth")]
[SerializeField] private PSXBPP bitDepth = PSXBPP.TEX_8BIT; // Defines the bit depth of the texture (e.g., 4BPP, 8BPP) [SerializeField] private PSXBPP bitDepth = PSXBPP.TEX_8BIT;
[SerializeField] private LuaFile luaFile; [SerializeField] private LuaFile luaFile;
[Header("Object Flags")]
[SerializeField] private PSXObjectFlags objectFlags = PSXObjectFlags.Static | PSXObjectFlags.Visible;
[Header("Collision Settings")]
[SerializeField] private PSXCollisionType collisionType = PSXCollisionType.None;
[SerializeField] private bool exportCollisionMesh = false;
[SerializeField] private Mesh customCollisionMesh; // Optional simplified collision mesh
[Tooltip("Layer mask for collision detection (1-8)")]
[Range(1, 8)]
[SerializeField] private int collisionLayer = 1;
[Header("Navigation")]
[Tooltip("Include this object's walkable surfaces in nav region generation")]
[SerializeField] private bool generateNavigation = false;
[Header("Gizmo Settings")] [Header("Gizmo Settings")]
[FormerlySerializedAs("PreviewNormals")] [FormerlySerializedAs("PreviewNormals")]
[SerializeField] private bool previewNormals = false; [SerializeField] private bool previewNormals = false;
[SerializeField] private float normalPreviewLength = 0.5f; // Length of the normal lines [SerializeField] private float normalPreviewLength = 0.5f;
[SerializeField] private bool showCollisionBounds = true;
// Public accessors for editor and export
public PSXBPP BitDepth => bitDepth;
public PSXCollisionType CollisionType => collisionType;
public bool ExportCollisionMesh => exportCollisionMesh;
public Mesh CustomCollisionMesh => customCollisionMesh;
public int CollisionLayer => collisionLayer;
public PSXObjectFlags ObjectFlags => objectFlags;
public bool GenerateNavigation => generateNavigation;
// For assigning texture from editor
public Texture2D texture;
private readonly Dictionary<(int, PSXBPP), PSXTexture2D> cache = new(); private readonly Dictionary<(int, PSXBPP), PSXTexture2D> cache = new();
@@ -35,31 +96,85 @@ namespace SplashEdit.RuntimeCode
Vector3[] vertices = mesh.vertices; Vector3[] vertices = mesh.vertices;
Vector3[] normals = mesh.normals; Vector3[] normals = mesh.normals;
Gizmos.color = Color.green; // Normal color Gizmos.color = Color.green;
for (int i = 0; i < vertices.Length; i++) for (int i = 0; i < vertices.Length; i++)
{ {
Vector3 worldVertex = transform.TransformPoint(vertices[i]); // Convert to world space Vector3 worldVertex = transform.TransformPoint(vertices[i]);
Vector3 worldNormal = transform.TransformDirection(normals[i]); // Transform normal to world space Vector3 worldNormal = transform.TransformDirection(normals[i]);
Gizmos.DrawLine(worldVertex, worldVertex + worldNormal * normalPreviewLength); Gizmos.DrawLine(worldVertex, worldVertex + worldNormal * normalPreviewLength);
} }
} }
} }
} }
private void OnDrawGizmosSelected()
{
// Draw collision bounds when object is selected
if (showCollisionBounds && collisionType != PSXCollisionType.None)
{
MeshFilter filter = GetComponent<MeshFilter>();
Mesh collisionMesh = customCollisionMesh != null ? customCollisionMesh : (filter?.sharedMesh);
if (collisionMesh != null)
{
Bounds bounds = collisionMesh.bounds;
// Choose color based on collision type
switch (collisionType)
{
case PSXCollisionType.Solid:
Gizmos.color = new Color(1f, 0.3f, 0.3f, 0.5f); // Red
break;
case PSXCollisionType.Trigger:
Gizmos.color = new Color(0.3f, 1f, 0.3f, 0.5f); // Green
break;
case PSXCollisionType.Platform:
Gizmos.color = new Color(0.3f, 0.3f, 1f, 0.5f); // Blue
break;
}
// Draw AABB
Matrix4x4 oldMatrix = Gizmos.matrix;
Gizmos.matrix = transform.localToWorldMatrix;
Gizmos.DrawWireCube(bounds.center, bounds.size);
// Draw filled with lower alpha
Color fillColor = Gizmos.color;
fillColor.a = 0.1f;
Gizmos.color = fillColor;
Gizmos.DrawCube(bounds.center, bounds.size);
Gizmos.matrix = oldMatrix;
}
}
}
/// <summary>
/// Converts the object's material texture into a PlayStation-compatible texture.
/// </summary>
///
public void CreatePSXTextures2D() public void CreatePSXTextures2D()
{ {
Renderer renderer = GetComponent<Renderer>(); Renderer renderer = GetComponent<Renderer>();
Textures.Clear(); Textures.Clear();
if (renderer != null) if (renderer != null)
{ {
// If an override texture is set, use it for all submeshes
if (texture != null)
{
PSXTexture2D tex;
if (cache.ContainsKey((texture.GetInstanceID(), bitDepth)))
{
tex = cache[(texture.GetInstanceID(), bitDepth)];
}
else
{
tex = PSXTexture2D.CreateFromTexture2D(texture, bitDepth);
tex.OriginalTexture = texture;
cache.Add((texture.GetInstanceID(), bitDepth), tex);
}
Textures.Add(tex);
return;
}
Material[] materials = renderer.sharedMaterials; Material[] materials = renderer.sharedMaterials;
foreach (Material mat in materials) foreach (Material mat in materials)
@@ -69,14 +184,12 @@ namespace SplashEdit.RuntimeCode
Texture mainTexture = mat.mainTexture; Texture mainTexture = mat.mainTexture;
Texture2D tex2D = null; Texture2D tex2D = null;
// Check if it's already a Texture2D
if (mainTexture is Texture2D existingTex2D) if (mainTexture is Texture2D existingTex2D)
{ {
tex2D = existingTex2D; tex2D = existingTex2D;
} }
else else
{ {
// If not a Texture2D, try to convert
tex2D = ConvertToTexture2D(mainTexture); tex2D = ConvertToTexture2D(mainTexture);
} }
@@ -90,7 +203,7 @@ namespace SplashEdit.RuntimeCode
else else
{ {
tex = PSXTexture2D.CreateFromTexture2D(tex2D, bitDepth); tex = PSXTexture2D.CreateFromTexture2D(tex2D, bitDepth);
tex.OriginalTexture = tex2D; // Store reference to the original texture tex.OriginalTexture = tex2D;
cache.Add((tex2D.GetInstanceID(), bitDepth), tex); cache.Add((tex2D.GetInstanceID(), bitDepth), tex);
} }
Textures.Add(tex); Textures.Add(tex);
@@ -102,10 +215,8 @@ namespace SplashEdit.RuntimeCode
private Texture2D ConvertToTexture2D(Texture texture) private Texture2D ConvertToTexture2D(Texture texture)
{ {
// Create a new Texture2D with the same dimensions and format
Texture2D texture2D = new Texture2D(texture.width, texture.height, TextureFormat.RGBA32, false); Texture2D texture2D = new Texture2D(texture.width, texture.height, TextureFormat.RGBA32, false);
// Read the texture pixels
RenderTexture currentActiveRT = RenderTexture.active; RenderTexture currentActiveRT = RenderTexture.active;
RenderTexture.active = texture as RenderTexture; RenderTexture.active = texture as RenderTexture;
@@ -116,9 +227,16 @@ namespace SplashEdit.RuntimeCode
return texture2D; return texture2D;
} }
/// <summary>
/// Converts the object's mesh into a PlayStation-compatible mesh. public PSXTexture2D GetTexture(int index)
/// </summary> {
if (index >= 0 && index < Textures.Count)
{
return Textures[index];
}
return null;
}
public void CreatePSXMesh(float GTEScaling) public void CreatePSXMesh(float GTEScaling)
{ {
Renderer renderer = GetComponent<Renderer>(); Renderer renderer = GetComponent<Renderer>();

View File

@@ -1,11 +1,2 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: bea0f31a495202580ac77bd9fd6e99f2 guid: a192e0a30d827ba40be5c99d32a83a12
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: e11677149a517ca5186e32dfda3ec088, type: 3}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,5 +1,4 @@
using UnityEngine; using UnityEngine;
using UnityEngine.AI;
using UnityEngine.Serialization; using UnityEngine.Serialization;
@@ -7,27 +6,81 @@ namespace SplashEdit.RuntimeCode
{ {
public class PSXPlayer : MonoBehaviour public class PSXPlayer : MonoBehaviour
{ {
private const float LookOutDistance = 1000f; [Header("Player Dimensions")]
[FormerlySerializedAs("PlayerHeight")] [FormerlySerializedAs("PlayerHeight")]
[SerializeField] private float playerHeight; [Tooltip("Camera eye height above the player's feet")]
[SerializeField] private float playerHeight = 1.8f;
[Tooltip("Collision radius for wall sliding")]
[SerializeField] private float playerRadius = 0.5f;
[Header("Movement")]
[Tooltip("Walk speed in world units per second")]
[SerializeField] private float moveSpeed = 3.0f;
[Tooltip("Sprint speed in world units per second")]
[SerializeField] private float sprintSpeed = 8.0f;
[Header("Navigation")]
[Tooltip("Maximum height the agent can step up")]
[SerializeField] private float maxStepHeight = 0.35f;
[Tooltip("Maximum walkable slope angle in degrees")]
[SerializeField] private float walkableSlopeAngle = 46.0f;
[Tooltip("Voxel size in XZ plane (smaller = more accurate but slower)")]
[SerializeField] private float navCellSize = 0.05f;
[Tooltip("Voxel height (smaller = more accurate vertical resolution)")]
[SerializeField] private float navCellHeight = 0.025f;
[Header("Jump & Gravity")]
[Tooltip("Peak jump height in world units")]
[SerializeField] private float jumpHeight = 2.0f;
[Tooltip("Downward acceleration in world units per second squared (positive value)")]
[SerializeField] private float gravity = 20.0f;
// Public accessors
public float PlayerHeight => playerHeight; public float PlayerHeight => playerHeight;
public float PlayerRadius => playerRadius;
public float MoveSpeed => moveSpeed;
public float SprintSpeed => sprintSpeed;
public float MaxStepHeight => maxStepHeight;
public float WalkableSlopeAngle => walkableSlopeAngle;
public float NavCellSize => navCellSize;
public float NavCellHeight => navCellHeight;
public float JumpHeight => jumpHeight;
public float Gravity => gravity;
public Vector3 CamPoint { get; protected set; } public Vector3 CamPoint { get; protected set; }
public void FindNavmesh() public void FindNavmesh()
{ {
if (NavMesh.SamplePosition(transform.position, out NavMeshHit hit, LookOutDistance, NavMesh.AllAreas)) // Raycast down from the transform to find the ground,
// then place CamPoint at ground + playerHeight
if (Physics.Raycast(transform.position, Vector3.down, out RaycastHit hit, 100f))
{ {
CamPoint = hit.position + new Vector3(0, PlayerHeight, 0); CamPoint = hit.point + new Vector3(0, playerHeight, 0);
}
else
{
// Fallback: no ground hit, use transform directly
CamPoint = transform.position + new Vector3(0, playerHeight, 0);
} }
} }
void OnDrawGizmos() void OnDrawGizmos()
{ {
FindNavmesh(); FindNavmesh();
// Red sphere at camera eye point
Gizmos.color = Color.red; Gizmos.color = Color.red;
Gizmos.DrawSphere(CamPoint, 0.2f); Gizmos.DrawSphere(CamPoint, 0.2f);
// Wireframe sphere at feet showing player radius
Gizmos.color = new Color(0f, 1f, 0f, 0.3f);
Vector3 feet = CamPoint - new Vector3(0, playerHeight, 0);
Gizmos.DrawWireSphere(feet, playerRadius);
} }
} }
} }

View File

@@ -1,11 +1,2 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: dee32f3a19300d7a3aae7424f01c9332 guid: 3cc71b54d0db2604087cd4ae7781dc98
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: 4d7bd095e76e6f3df976224b15405059, type: 3}
userData:
assetBundleName:
assetBundleVariant:

Some files were not shown because too many files have changed in this diff Show More