diff --git a/CMakeLists.txt b/CMakeLists.txt index 88fbca52c7..1e7f93bb1d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -406,6 +406,7 @@ include_directories( ${dr_libs_SOURCE_DIR} ) +set(ENABLE_PRESS_TO_JOIN ON) add_subdirectory(libultraship ${CMAKE_CURRENT_SOURCE_DIR}/libultraship) add_dependencies(${PROJECT_NAME} libultraship) target_link_libraries(${PROJECT_NAME} PRIVATE libultraship) diff --git a/assets/shaders/opengl/default.shader.fs b/assets/shaders/opengl/default.shader.fs deleted file mode 100644 index 65e9654045..0000000000 --- a/assets/shaders/opengl/default.shader.fs +++ /dev/null @@ -1,218 +0,0 @@ -@prism(type='fragment', name='Fast3D Fragment Shader', version='1.0.0', description='Ported shader to prism', author='Emill & Prism Team') - -@{GLSL_VERSION} - -@if(core_opengl || opengles) -out vec4 vOutColor; -@end - -@for(i in 0..2) - @if(o_textures[i]) - @{attr} vec2 vTexCoord@{i}; - @for(j in 0..2) - @if(o_clamp[i][j]) - @if(j == 0) - @{attr} float vTexClampS@{i}; - @else - @{attr} float vTexClampT@{i}; - @end - @end - @end - @end -@end - -@if(o_fog) @{attr} vec4 vFog; -@if(o_grayscale) @{attr} vec4 vGrayscaleColor; - -@for(i in 0..o_inputs) - @if(o_alpha) - @{attr} vec4 vInput@{i + 1}; - @else - @{attr} vec3 vInput@{i + 1}; - @end -@end - -@if(o_textures[0]) uniform sampler2D uTex0; -@if(o_textures[1]) uniform sampler2D uTex1; - -@if(o_masks[0]) uniform sampler2D uTexMask0; -@if(o_masks[1]) uniform sampler2D uTexMask1; - -@if(o_blend[0]) uniform sampler2D uTexBlend0; -@if(o_blend[1]) uniform sampler2D uTexBlend1; - -uniform int frame_count; -uniform float noise_scale; - -uniform int texture_width[2]; -uniform int texture_height[2]; -uniform int texture_filtering[2]; - -#define TEX_OFFSET(off) @{texture}(tex, texCoord - off / texSize) -#define WRAP(x, low, high) mod((x)-(low), (high)-(low)) + (low) - -float random(in vec3 value) { - float random = dot(sin(value), vec3(12.9898, 78.233, 37.719)); - return fract(sin(random) * 143758.5453); -} - -vec4 fromLinear(vec4 linearRGB){ - bvec3 cutoff = lessThan(linearRGB.rgb, vec3(0.0031308)); - vec3 higher = vec3(1.055)*pow(linearRGB.rgb, vec3(1.0/2.4)) - vec3(0.055); - vec3 lower = linearRGB.rgb * vec3(12.92); - return vec4(mix(higher, lower, cutoff), linearRGB.a); -} - -vec4 filter3point(in sampler2D tex, in vec2 texCoord, in vec2 texSize) { - vec2 offset = fract(texCoord*texSize - vec2(0.5)); - offset -= step(1.0, offset.x + offset.y); - vec4 c0 = TEX_OFFSET(offset); - vec4 c1 = TEX_OFFSET(vec2(offset.x - sign(offset.x), offset.y)); - vec4 c2 = TEX_OFFSET(vec2(offset.x, offset.y - sign(offset.y))); - return c0 + abs(offset.x)*(c1-c0) + abs(offset.y)*(c2-c0); -} - -vec4 hookTexture2D(in int id, sampler2D tex, in vec2 uv, in vec2 texSize) { -@if(o_three_point_filtering) - if(texture_filtering[id] == @{FILTER_THREE_POINT}) { - return filter3point(tex, uv, texSize); - } -@end - return @{texture}(tex, uv); -} - -#define TEX_SIZE(tex) vec2(texture_width[tex], texture_height[tex]) - -void main() { - @for(i in 0..2) - @if(o_textures[i]) - @{s = o_clamp[i][0]} - @{t = o_clamp[i][1]} - - vec2 texSize@{i} = TEX_SIZE(@{i}); - - @if(!s && !t) - vec2 vTexCoordAdj@{i} = vTexCoord@{i}; - @else - @if(s && t) - vec2 vTexCoordAdj@{i} = clamp(vTexCoord@{i}, 0.5 / texSize@{i}, vec2(vTexClampS@{i}, vTexClampT@{i})); - @elseif(s) - vec2 vTexCoordAdj@{i} = vec2(clamp(vTexCoord@{i}.s, 0.5 / texSize@{i}.s, vTexClampS@{i}), vTexCoord@{i}.t); - @else - vec2 vTexCoordAdj@{i} = vec2(vTexCoord@{i}.s, clamp(vTexCoord@{i}.t, 0.5 / texSize@{i}.t, vTexClampT@{i})); - @end - @end - - vec4 texVal@{i} = hookTexture2D(@{i}, uTex@{i}, vTexCoordAdj@{i}, texSize@{i}); - - @if(o_masks[i]) - @if(opengles) - vec2 maskSize@{i} = vec2(textureSize(uTexMask@{i}, 0)); - @else - vec2 maskSize@{i} = textureSize(uTexMask@{i}, 0); - @end - - vec4 maskVal@{i} = hookTexture2D(@{i}, uTexMask@{i}, vTexCoordAdj@{i}, maskSize@{i}); - - @if(o_blend[i]) - vec4 blendVal@{i} = hookTexture2D(@{i}, uTexBlend@{i}, vTexCoordAdj@{i}, texSize@{i}); - @else - vec4 blendVal@{i} = vec4(0, 0, 0, 0); - @end - - texVal@{i} = mix(texVal@{i}, blendVal@{i}, maskVal@{i}.a); - @end - @end - @end - - @if(o_alpha) - vec4 texel; - @else - vec3 texel; - @end - - @if(o_2cyc) - @{f_range = 2} - @else - @{f_range = 1} - @end - - @for(c in 0..f_range) - @if(c == 1) - @if(o_alpha) - @if(o_c[c][1][2] == SHADER_COMBINED) - texel.a = WRAP(texel.a, -1.01, 1.01); - @else - texel.a = WRAP(texel.a, -0.51, 1.51); - @end - @end - - @if(o_c[c][0][2] == SHADER_COMBINED) - texel.rgb = WRAP(texel.rgb, -1.01, 1.01); - @else - texel.rgb = WRAP(texel.rgb, -0.51, 1.51); - @end - @end - - @if(!o_color_alpha_same[c] && o_alpha) - texel = vec4(@{ - append_formula(o_c[c], o_do_single[c][0], - o_do_multiply[c][0], o_do_mix[c][0], false, false, true, c == 0) - }, @{append_formula(o_c[c], o_do_single[c][1], - o_do_multiply[c][1], o_do_mix[c][1], true, true, true, c == 0) - }); - @else - texel = @{append_formula(o_c[c], o_do_single[c][0], - o_do_multiply[c][0], o_do_mix[c][0], o_alpha, false, - o_alpha, c == 0)}; - @end - @end - - texel = WRAP(texel, -0.51, 1.51); - texel = clamp(texel, 0.0, 1.0); - // TODO discard if alpha is 0? - @if(o_fog) - @if(o_alpha) - texel = vec4(mix(texel.rgb, vFog.rgb, vFog.a), texel.a); - @else - texel = mix(texel, vFog.rgb, vFog.a); - @end - @end - - @if(o_texture_edge && o_alpha) - if (texel.a > 0.19) texel.a = 1.0; else discard; - @end - - @if(o_alpha && o_noise) - texel.a *= floor(clamp(random(vec3(floor(gl_FragCoord.xy * noise_scale), float(frame_count))) + texel.a, 0.0, 1.0)); - @end - - @if(o_grayscale) - float p = float(0x19); - float r = texel.r*float(0x55); - float g = texel.g*float(0x4B); - float b = texel.b*float(0x5F); - float intensity = (r+g+b) / 256.0; - intensity *= 255.0/256.0; - intensity = pow(intensity, (p * 1.5 / 256.0) + 0.25); - intensity *= 31.0/32.0; - vec3 new_texel = vGrayscaleColor.rgb * intensity; - texel.rgb = mix(texel.rgb, new_texel, vGrayscaleColor.a); - @end - - @if(o_alpha) - @if(o_alpha_threshold) - if (texel.a < 8.0 / 256.0) discard; - @end - @if(o_invisible) - texel.a = 0.0; - @end - @{vOutColor} = texel; - @else - @{vOutColor} = vec4(texel, 1.0); - @end - - @if(srgb_mode) - @{vOutColor} = fromLinear(@{vOutColor}); - @end -} \ No newline at end of file diff --git a/assets/shaders/opengl/default.shader.glsl b/assets/shaders/opengl/default.shader.glsl new file mode 100644 index 0000000000..5e0f93f292 --- /dev/null +++ b/assets/shaders/opengl/default.shader.glsl @@ -0,0 +1,296 @@ +@prism(type='fragment', name='Fast3D Fragment Shader', version='1.0.0', description='Ported shader to prism', author='Emill & Prism Team') + +@{GLSL_VERSION} + +@if(VERTEX_SHADER) + @{attr} vec4 aVtxPos; + + @for(i in 0..2) + @if(o_textures[i]) + @{attr} vec2 aTexCoord@{i}; + @{out} vec2 vTexCoord@{i}; + @{update_floats(2)} + @for(j in 0..2) + @if(o_clamp[i][j]) + @if(j == 0) + @{attr} float aTexClampS@{i}; + @{out} float vTexClampS@{i}; + @else + @{attr} float aTexClampT@{i}; + @{out} float vTexClampT@{i}; + @end + @{update_floats(1)} + @end + @end + @end + @end + + @if(o_fog) + @{attr} vec4 aFog; + @{out} vec4 vFog; + @{update_floats(4)} + @end + + @if(o_grayscale) + @{attr} vec4 aGrayscaleColor; + @{out} vec4 vGrayscaleColor; + @{update_floats(4)} + @end + + @for(i in 0..o_inputs) + @if(o_alpha) + @{attr} vec4 aInput@{i + 1}; + @{out} vec4 vInput@{i + 1}; + @{update_floats(4)} + @else + @{attr} vec3 aInput@{i + 1}; + @{out} vec3 vInput@{i + 1}; + @{update_floats(3)} + @end + @end + + void main() { + @for(i in 0..2) + @if(o_textures[i]) + vTexCoord@{i} = aTexCoord@{i}; + @for(j in 0..2) + @if(o_clamp[i][j]) + @if(j == 0) + vTexClampS@{i} = aTexClampS@{i}; + @else + vTexClampT@{i} = aTexClampT@{i}; + @end + @end + @end + @end + @end + @if(o_fog) + vFog = aFog; + @end + @if(o_grayscale) + vGrayscaleColor = aGrayscaleColor; + @end + @for(i in 0..o_inputs) + vInput@{i + 1} = aInput@{i + 1}; + @end + gl_Position = aVtxPos; + @if(opengles) + gl_Position.z *= 0.3f; + @end + } +@else + @if(core_opengl || opengles) + out vec4 vOutColor; + @end + + @for(i in 0..2) + @if(o_textures[i]) + @{attr} vec2 vTexCoord@{i}; + @for(j in 0..2) + @if(o_clamp[i][j]) + @if(j == 0) + @{attr} float vTexClampS@{i}; + @else + @{attr} float vTexClampT@{i}; + @end + @end + @end + @end + @end + + @if(o_fog) @{attr} vec4 vFog; + @if(o_grayscale) @{attr} vec4 vGrayscaleColor; + + @for(i in 0..o_inputs) + @if(o_alpha) + @{attr} vec4 vInput@{i + 1}; + @else + @{attr} vec3 vInput@{i + 1}; + @end + @end + + @if(o_textures[0]) uniform sampler2D uTex0; + @if(o_textures[1]) uniform sampler2D uTex1; + + @if(o_masks[0]) uniform sampler2D uTexMask0; + @if(o_masks[1]) uniform sampler2D uTexMask1; + + @if(o_blend[0]) uniform sampler2D uTexBlend0; + @if(o_blend[1]) uniform sampler2D uTexBlend1; + + uniform int frame_count; + uniform float noise_scale; + + uniform int texture_width[2]; + uniform int texture_height[2]; + uniform int texture_filtering[2]; + + #define TEX_OFFSET(off) @{texture}(tex, texCoord - off / texSize) + #define WRAP(x, low, high) mod((x)-(low), (high)-(low)) + (low) + + float random(in vec3 value) { + float random = dot(sin(value), vec3(12.9898, 78.233, 37.719)); + return fract(sin(random) * 143758.5453); + } + + vec4 fromLinear(vec4 linearRGB){ + bvec3 cutoff = lessThan(linearRGB.rgb, vec3(0.0031308)); + vec3 higher = vec3(1.055)*pow(linearRGB.rgb, vec3(1.0/2.4)) - vec3(0.055); + vec3 lower = linearRGB.rgb * vec3(12.92); + return vec4(mix(higher, lower, cutoff), linearRGB.a); + } + + vec4 filter3point(in sampler2D tex, in vec2 texCoord, in vec2 texSize) { + vec2 offset = fract(texCoord*texSize - vec2(0.5)); + offset -= step(1.0, offset.x + offset.y); + vec4 c0 = TEX_OFFSET(offset); + vec4 c1 = TEX_OFFSET(vec2(offset.x - sign(offset.x), offset.y)); + vec4 c2 = TEX_OFFSET(vec2(offset.x, offset.y - sign(offset.y))); + return c0 + abs(offset.x)*(c1-c0) + abs(offset.y)*(c2-c0); + } + + vec4 hookTexture2D(in int id, sampler2D tex, in vec2 uv, in vec2 texSize) { + @if(o_three_point_filtering) + if(texture_filtering[id] == @{FILTER_THREE_POINT}) { + return filter3point(tex, uv, texSize); + } + @end + return @{texture}(tex, uv); + } + + #define TEX_SIZE(tex) vec2(texture_width[tex], texture_height[tex]) + + void main() { + @for(i in 0..2) + @if(o_textures[i]) + @{s = o_clamp[i][0]} + @{t = o_clamp[i][1]} + + vec2 texSize@{i} = TEX_SIZE(@{i}); + + @if(!s && !t) + vec2 vTexCoordAdj@{i} = vTexCoord@{i}; + @else + @if(s && t) + vec2 vTexCoordAdj@{i} = clamp(vTexCoord@{i}, 0.5 / texSize@{i}, vec2(vTexClampS@{i}, vTexClampT@{i})); + @elseif(s) + vec2 vTexCoordAdj@{i} = vec2(clamp(vTexCoord@{i}.s, 0.5 / texSize@{i}.s, vTexClampS@{i}), vTexCoord@{i}.t); + @else + vec2 vTexCoordAdj@{i} = vec2(vTexCoord@{i}.s, clamp(vTexCoord@{i}.t, 0.5 / texSize@{i}.t, vTexClampT@{i})); + @end + @end + + vec4 texVal@{i} = hookTexture2D(@{i}, uTex@{i}, vTexCoordAdj@{i}, texSize@{i}); + + @if(o_masks[i]) + @if(opengles) + vec2 maskSize@{i} = vec2(textureSize(uTexMask@{i}, 0)); + @else + vec2 maskSize@{i} = textureSize(uTexMask@{i}, 0); + @end + + vec4 maskVal@{i} = hookTexture2D(@{i}, uTexMask@{i}, vTexCoordAdj@{i}, maskSize@{i}); + + @if(o_blend[i]) + vec4 blendVal@{i} = hookTexture2D(@{i}, uTexBlend@{i}, vTexCoordAdj@{i}, texSize@{i}); + @else + vec4 blendVal@{i} = vec4(0, 0, 0, 0); + @end + + texVal@{i} = mix(texVal@{i}, blendVal@{i}, maskVal@{i}.a); + @end + @end + @end + + @if(o_alpha) + vec4 texel; + @else + vec3 texel; + @end + + @if(o_2cyc) + @{f_range = 2} + @else + @{f_range = 1} + @end + + @for(c in 0..f_range) + @if(c == 1) + @if(o_alpha) + @if(o_c[c][1][2] == SHADER_COMBINED) + texel.a = WRAP(texel.a, -1.01, 1.01); + @else + texel.a = WRAP(texel.a, -0.51, 1.51); + @end + @end + + @if(o_c[c][0][2] == SHADER_COMBINED) + texel.rgb = WRAP(texel.rgb, -1.01, 1.01); + @else + texel.rgb = WRAP(texel.rgb, -0.51, 1.51); + @end + @end + + @if(!o_color_alpha_same[c] && o_alpha) + texel = vec4(@{ + append_formula(o_c[c], o_do_single[c][0], + o_do_multiply[c][0], o_do_mix[c][0], false, false, true, c == 0) + }, @{append_formula(o_c[c], o_do_single[c][1], + o_do_multiply[c][1], o_do_mix[c][1], true, true, true, c == 0) + }); + @else + texel = @{append_formula(o_c[c], o_do_single[c][0], + o_do_multiply[c][0], o_do_mix[c][0], o_alpha, false, + o_alpha, c == 0)}; + @end + @end + + texel = WRAP(texel, -0.51, 1.51); + texel = clamp(texel, 0.0, 1.0); + // TODO discard if alpha is 0? + @if(o_fog) + @if(o_alpha) + texel = vec4(mix(texel.rgb, vFog.rgb, vFog.a), texel.a); + @else + texel = mix(texel, vFog.rgb, vFog.a); + @end + @end + + @if(o_texture_edge && o_alpha) + if (texel.a > 0.19) texel.a = 1.0; else discard; + @end + + @if(o_alpha && o_noise) + texel.a *= floor(clamp(random(vec3(floor(gl_FragCoord.xy * noise_scale), float(frame_count))) + texel.a, 0.0, 1.0)); + @end + + @if(o_grayscale) + float p = float(0x19); + float r = texel.r*float(0x55); + float g = texel.g*float(0x4B); + float b = texel.b*float(0x5F); + float intensity = (r+g+b) / 256.0; + intensity *= 255.0/256.0; + intensity = pow(intensity, (p * 1.5 / 256.0) + 0.25); + intensity *= 31.0/32.0; + vec3 new_texel = vGrayscaleColor.rgb * intensity; + texel.rgb = mix(texel.rgb, new_texel, vGrayscaleColor.a); + @end + + @if(o_alpha) + @if(o_alpha_threshold) + if (texel.a < 8.0 / 256.0) discard; + @end + @if(o_invisible) + texel.a = 0.0; + @end + @{vOutColor} = texel; + @else + @{vOutColor} = vec4(texel, 1.0); + @end + + @if(srgb_mode) + @{vOutColor} = fromLinear(@{vOutColor}); + @end + } +@end diff --git a/assets/shaders/opengl/default.shader.vs b/assets/shaders/opengl/default.shader.vs deleted file mode 100644 index cf0f62765a..0000000000 --- a/assets/shaders/opengl/default.shader.vs +++ /dev/null @@ -1,79 +0,0 @@ -@prism(type='fragment', name='Fast3D Fragment Shader', version='1.0.0', description='Ported shader to prism', author='Emill & Prism Team') - -@{GLSL_VERSION} - -@{attr} vec4 aVtxPos; - -@for(i in 0..2) - @if(o_textures[i]) - @{attr} vec2 aTexCoord@{i}; - @{out} vec2 vTexCoord@{i}; - @{update_floats(2)} - @for(j in 0..2) - @if(o_clamp[i][j]) - @if(j == 0) - @{attr} float aTexClampS@{i}; - @{out} float vTexClampS@{i}; - @else - @{attr} float aTexClampT@{i}; - @{out} float vTexClampT@{i}; - @end - @{update_floats(1)} - @end - @end - @end -@end - -@if(o_fog) - @{attr} vec4 aFog; - @{out} vec4 vFog; - @{update_floats(4)} -@end - -@if(o_grayscale) - @{attr} vec4 aGrayscaleColor; - @{out} vec4 vGrayscaleColor; - @{update_floats(4)} -@end - -@for(i in 0..o_inputs) - @if(o_alpha) - @{attr} vec4 aInput@{i + 1}; - @{out} vec4 vInput@{i + 1}; - @{update_floats(4)} - @else - @{attr} vec3 aInput@{i + 1}; - @{out} vec3 vInput@{i + 1}; - @{update_floats(3)} - @end -@end - -void main() { - @for(i in 0..2) - @if(o_textures[i]) - vTexCoord@{i} = aTexCoord@{i}; - @for(j in 0..2) - @if(o_clamp[i][j]) - @if(j == 0) - vTexClampS@{i} = aTexClampS@{i}; - @else - vTexClampT@{i} = aTexClampT@{i}; - @end - @end - @end - @end - @end - @if(o_fog) - vFog = aFog; - @end - @if(o_grayscale) - vGrayscaleColor = aGrayscaleColor; - @end - @for(i in 0..o_inputs) - vInput@{i + 1} = aInput@{i + 1}; - @end - gl_Position = aVtxPos; - @if(opengles) - gl_Position.z *= 0.3f; - @end -} \ No newline at end of file diff --git a/docs/press-to-join.md b/docs/press-to-join.md new file mode 100644 index 0000000000..c3ef81dc56 --- /dev/null +++ b/docs/press-to-join.md @@ -0,0 +1,251 @@ +# Press-to-Join Multiplayer Controller System + +## Context + +LUS PR #792/#793 simplified the controller system by putting all devices on port 0. Multiplayer still works, but requires manually opening the controller menu and assigning physical devices to ports every launch. Open PR #964 (auto-distribute by enumeration) has been stalled 4+ months with fundamental issues. Issue #1058 asks for persistent device IDs, which is a known bad path. + +This implements a press-to-join model where device identity never matters — only user actions drive assignment, removing the need for manual controller menu configuration. + +Core principle: never try to identify a controller. Phantom controllers never press buttons. Duplicate devices don't matter because the one the user is holding is the one that fires input. Device identity doesn't need to survive reconnects because the user just presses a button again. + +## Three States + +1. **Single player** — all devices on port 0 (current default behavior) +2. **Press-to-join active** — character select screen only. LUS detects unassigned device input and assigns to the next empty active port. On disconnect, port is freed so it can be filled again by the next press. This is the only screen where players have direct visual feedback (cursor/border) for joins and disconnects +3. **Press-to-join inactive** — anywhere outside character select (map select, racing, etc.). LUS does nothing automatic. Ports stay as they are. Controllers do unpredictable things — we don't silently reassign when there's no visual feedback + +Transitions: +- User picks 2P+, enters character select → state 1 → state 2 (`MultiplayerStart`) +- Leave character select forward to map select → state 2 → state 3 (`StopPressToJoin`) +- Return to character select from map select → state 3 → state 2 (`StartPressToJoin`) +- Back to main menu from character select → state 2 → state 1 (`MultiplayerStop`) +- Back to main menu from race/map select → state 3 → state 1 (`MultiplayerStop`) + +## Feature Gate + +**CMake option:** `ENABLE_PRESS_TO_JOIN` (default OFF in LUS, ON in SpaghettiKart's CMakeLists.txt) + +This gates: +- The press-to-join checkbox in `InputEditorWindow.cpp` (`libultraship/src/ship/window/gui/InputEditorWindow.cpp`) +- All press-to-join state and methods in `ConnectedPhysicalDeviceManager` +- The bridge functions in `multiplayerbridge.h` + +**CVar:** `gPressToJoinEnabled` (default ON when feature is compiled in). Controlled by the checkbox in the Input Editor. All bridge functions no-op when disabled. + +Games that don't set `ENABLE_PRESS_TO_JOIN` get no checkbox, no press-to-join code, no behavior change. + +## Implementation + +### Part 1: LUS — Extend ConnectedPhysicalDeviceManager + +**Files:** +- `libultraship/include/ship/controller/physicaldevice/ConnectedPhysicalDeviceManager.h` +- `libultraship/src/ship/controller/physicaldevice/ConnectedPhysicalDeviceManager.cpp` + +**New private state:** +```cpp +bool mMultiplayerActive = false; // true in states 2 and 3 +bool mPressToJoinActive = false; // true only in state 2 +uint8_t mMultiplayerPortCount = 1; +std::unordered_map mPressToJoinAssignments; // port → instanceId +``` + +`mPressToJoinAssignments` is bookkeeping for the automatic system only. The real source of truth for what device is on what port is always the existing ignore lists. If someone manually changes assignments via the Input Editor, those take effect immediately — this map may become stale, and that's fine. + +**New public methods:** + +| Method | Purpose | +|--------|---------| +| `StartMultiplayer(uint8_t portCount)` | Enter state 2. Ignore all devices on all active ports (everyone starts unassigned). Sets both `mMultiplayerActive` and `mPressToJoinActive`. Press-to-join fills ports starting at 0 — first press is P1, second is P2, etc. | +| `StopPressToJoin()` | Enter state 3. Sets `mPressToJoinActive = false`. Assignments stay, LUS stops all automatic behavior | +| `StartPressToJoin()` | Re-enter state 2 (e.g. back to character select from map select). Sets `mPressToJoinActive = true`. Frees any assigned ports whose devices are disconnected, so they can be re-joined | +| `StopMultiplayer()` | Back to state 1. Wipe `mIgnoredInstanceIds`, then call `RefreshConnectedSDLGamepads()` — the wipe matters because port 0 accumulated "ignore everyone except the assigned device" during multiplayer, and refresh alone only repopulates ports 1-3 | +| `GetPortDeviceStatus(uint8_t port)` | 0 = unassigned, 1 = assigned+connected, -1 = assigned+disconnected | + +LUS handles press-to-join detection and assignment internally — when `mPressToJoinActive` is true, it polls for input each frame and assigns to empty active ports. The game doesn't need to drive this — it just reads port status. + +**Polling logic (each frame when `mPressToJoinActive`):** + +Two-phase timing guards: + +1. **Activation grace (15 frames):** skip polling for the first 15 frames after `mPressToJoinActive` goes true. The button that triggered the menu transition into char select is typically still held for a few frames, and we don't want it counted. Implemented via function-local static frame counter. + +2. **Release-edge detection:** track per-device "had any input last frame" state. Assign on the transition held→not-held. The alternative (assign on press) fails because `ReadToPad` runs right after `PollPressToJoin` in the same frame — if we claim the port while the button is still held, the game sees the join press as a confirm/select. Assigning on release means SDL state reads empty on the assign frame. + +Inside those guards, iterate active ports in order (0 through portCount-1). For each port without an assigned device: +1. If this port has keyboard mappings and `Window::GetLastScancode() != -1` → assign keyboard (pseudo instance ID `-2`). Keyboard uses `GetLastScancode` which is already event-based (fires once per press), so release-edge isn't required there. +2. Otherwise, pick the first device in the release-edge set that isn't already assigned to another port. + +Assignment always goes to the lowest-indexed empty active port. If port 2 disconnects while ports 0, 1, 3 are active, the next press fills port 2. + +Keyboard can only join the port it's already mapped to (usually port 0). There's no concept of ignoring keyboard, and we don't move keyboard mappings at runtime. This is a POC limitation. Gamepads fill remaining ports as normal. + +**Modify existing methods:** + +- `RefreshConnectedSDLGamepads()`: Wrap the "ignore all on ports 1-3" logic in `if (!mMultiplayerActive)`. When multiplayer IS active, rebuild ignore lists from `mPressToJoinAssignments` instead — for each active port, ignore all devices except the assigned one; for unassigned ports, ignore everything. + +- `HandlePhysicalDeviceDisconnect()`: After refresh, if `mPressToJoinActive` and disconnected device was in `mPressToJoinAssignments`, free the port — the next press will fill it again. If NOT `mPressToJoinActive` (racing), don't touch anything — the port stays assigned-but-disconnected. No input flows, kart sits idle. + +### Part 2: LUS — C Bridge + +**New files:** +- `libultraship/include/libultraship/bridge/multiplayerbridge.h` +- `libultraship/src/libultraship/bridge/multiplayerbridge.cpp` + +**Modify:** +- `libultraship/include/libultraship/bridge.h` — add `#include` for new header + +Bridge functions (extern "C", follows pattern from `controllerbridge.h`): + +```c +void MultiplayerStart(uint8_t portCount); +void MultiplayerStopPressToJoin(void); +void MultiplayerStartPressToJoin(void); +void MultiplayerStop(void); +int8_t MultiplayerGetPortStatus(uint8_t port); +``` + +All functions check the `gPressToJoinEnabled` CVar internally and no-op when disabled. The game always calls them — it doesn't need to check the CVar itself. + +Each function reaches through `Ship::Context::GetInstance()->GetControlDeck()->GetConnectedPhysicalDeviceManager()->...` + +### Part 3: LUS — Input Editor Changes + +**File:** `libultraship/src/ship/window/gui/InputEditorWindow.cpp` (device assignment section around lines 1193-1216) + +Gated behind `#ifdef ENABLE_PRESS_TO_JOIN`: + +- Add a "Press-to-Join" checkbox next to the per-device-per-port assignment checkboxes, bound to the `gPressToJoinEnabled` CVar +- When the CVar is ON: disable (grey out) the per-device-per-port checkboxes — press-to-join is managing assignments +- When the CVar is OFF: per-device-per-port checkboxes are fully editable. User has full manual control + +### Part 4: SpaghettiKart — CMake + Game Select Hook + +**File:** `CMakeLists.txt` — add `set(ENABLE_PRESS_TO_JOIN ON)` before the `add_subdirectory(libultraship)` call. + +**File:** `src/menus.c` + +**At line 1512** (A_BUTTON in `MAIN_MENU_OK_SELECT` — the final confirmation before entering character select): When `gPlayerCount >= 2`, start multiplayer. + +```c +} else if (btnAndStick & A_BUTTON) { + if (gPlayerCount >= 2) { + MultiplayerStart(gPlayerCount); + } + func_8009E1C0(); + play_sound2(SOUND_MENU_OK_CLICKED); + setup_game_mode_selected(); + // ... existing code +} +``` + +This is the moment the user confirms their game mode and transitions into character select. `MultiplayerStart` ignores all devices on all active ports — nobody is assigned yet. Press-to-join on character select will fill ports starting at 0. The person who was navigating menus just presses a button on character select to claim P1. + +Keyboard input bypasses the SDL ignore system entirely (goes through `ProcessKeyboardEvent` which isn't filtered), so it works on port 0 regardless. + +### Part 5: SpaghettiKart — Character Select Press-to-Join + +**Files:** `src/menus.c`, `src/menu_items.c` + +Single source of truth for "is this port joined" is `MultiplayerGetPortStatus(port) == 1`. We deliberately do not overload `gCharacterGridSelections == 0` to mean "unjoined." + +**Character select init (menus.c, `MENU_FADE_TYPE_MAIN` case):** Always set `gCharacterGridSelections[i] = i + 1` for `i < gPlayerCount`, regardless of press-to-join state. Unjoined cursors still render at their default grid slot — just greyed out. + +**Per-frame logic (menus.c, `player_select_menu_act`):** On `PLAYER_ONE`'s call, iterate ports and detect status transitions 0→1 to play the join sound. A `static s8 sPrevPortStatus[MAXCONTROLLERS]` tracks previous-frame status. Grid selection itself is never modified here; the cursor's *position* is already driven by grid, and its *color* is driven by port status. + +**Input routing for unjoined ports:** No explicit gate needed. The ignore lists handle it: every unjoined port has every device in its ignore list, so `controller->ReadToPad` writes zero and `btnAndStick` is 0. The switch cases never see input. (Edge case: if the user manually configured keyboard mappings on port 2/3, keyboard input could leak to those unjoined ports — POC ignores this.) + +**Cursor render (menu_items.c, `render_cursor_player`):** When press-to-join is enabled and `MultiplayerGetPortStatus(port) != 1`, wrap the cursor render in `gDPSetGrayscaleColor(0xFF, 0xFF, 0xFF, 0xFF)` + `gSPGrayscale(true/false)`. Keeps the colored frame and number-badge visuals intact but desaturates them. This is the same mechanism used by `MAIN_MENU_BACKGROUND` rendering elsewhere in the file. + +Using `gSPGrayscale` rather than swapping prim color: each player's border is two chained textures, and only the first responds to prim color tint. The second (`gTextureP1BorderBlue` etc.) has color baked into texture data. Grayscale post-process desaturates both. + +**All-selected check (menus.c, around line 1618):** transition to OK state only when no cursor is unconfirmed AND (if press-to-join is active) every active port has `MultiplayerGetPortStatus == 1`. + +### Part 6: SpaghettiKart — Race Start / Menu Return Hooks + +**File:** `src/menus.c` + +| Location | Call | +|----------|------| +| A in `PLAYER_SELECT_MENU_OK` case (forward to map select) | `MultiplayerStopPressToJoin()` | +| B in `SUB_MENU_MAP_SELECT_CUP` (back to char select) | `MultiplayerStartPressToJoin()` | +| B in `SUB_MENU_MAP_SELECT_BATTLE_COURSE` (back to char select) | `MultiplayerStartPressToJoin()` | +| B in `PLAYER_SELECT_MENU_MAIN` (back to main menu, both `savedSelection == 0` and cursor paths) | `MultiplayerStop()` | + +The planned "B in `MAIN_MENU_MODE_SELECT`" hook wasn't needed in practice — `MultiplayerStart` only fires at A in `MAIN_MENU_OK_SELECT`, so there's no active multiplayer state to tear down when backing out of mode select. + +### Part 7: Disconnect During Race (minimal POC) + +Controller disconnects during race → kart sits idle, race continues. `mPressToJoinActive` is false, so LUS does nothing automatic. No port freeing, no reassignment, no UI. The kart just stops receiving input. + +If the player needs time, they ask another player to pause (existing pause mechanic). Social layer handles it. + +Future enhancement: game polls `MultiplayerGetPortStatus()` to detect -1 (assigned+disconnected) and show a notification via LUS notification system. + +### Part 8: Defaults on ports 1-3 (POC) + +**File:** `libultraship/src/ship/controller/controldeck/ControlDeck.cpp`, gated behind `#ifdef ENABLE_PRESS_TO_JOIN`. + +Stock LUS only applies default mappings to port 0. Without mappings on ports 1-3, press-to-join flips the ignore lists but `ReadToPad` has nothing to translate SDL state into OSContPad — the game gets zero input on joined ports 2/3/4. + +For the POC, `ControlDeck::Init` extends the existing port-0 defaults block with a follow-up loop that calls `AddDefaultMappings(PhysicalDeviceType::SDLGamepad)` on each other port that has `HasConfig() == false`. Keyboard/Mouse are excluded — those only make sense on one port. + +**This is flagged as POC in code.** A proper solution would let games express per-port defaults via `ControllerDefaultMappings` rather than hardcoding a loop in library code. The current hack is unconditional when `ENABLE_PRESS_TO_JOIN` is on, which is fine for Spaghetti but wouldn't generalize. + +## Keyboard Handling + +Keyboard has no SDL instance ID and no ignore system — it's fundamentally different from gamepads. `ProcessKeyboardEvent` in `ControlDeck.cpp` (line 43) broadcasts keyboard events to all ports, but only ports with keyboard mappings configured respond. + +**Press-to-join detection:** When polling empty active ports, check if the port has keyboard mappings configured and `Window::GetLastScancode() != -1`. If so, assign keyboard (pseudo instance ID `-2`) to that port. + +**Keyboard can only join the port it's mapped to** (default port 0). We don't move keyboard mappings at runtime and there's no keyboard ignore system. This is a POC limitation — a full implementation could add keyboard port reassignment. + +**What this means in practice:** +- Keyboard user enters 2P → presses a key on character select → joins port 0 (where keyboard is mapped) → gamepad press fills port 1 +- Gamepad-only user enters 2P → first gamepad press fills port 0, second fills port 1. Keyboard mapped to port 0 is also there but doesn't matter since nobody is pressing keys +- If keyboard is configured on port 2 for some reason, keyboard join goes to port 2 + +## Key Design Notes +- **No new classes needed**: All state lives in `ConnectedPhysicalDeviceManager` +- **No persistence**: RAM-only, resets on launch +- **Thread safety**: All on main thread (SDL events, game loop, menus) +- **LUS handles press-to-join internally**: The game doesn't drive detection or assignment — it just reads port status. LUS polls unassigned gamepads via raw SDL when `mPressToJoinActive` is true +- **Press-to-join assignments are bookkeeping only**: The `mPressToJoinAssignments` map tracks what the automatic system did — the ignore lists remain the real source of truth for input routing +- **Game-side source of truth is the status bridge**: `MultiplayerGetPortStatus` is the only signal the game consults for "is this port joined." Grid selection is never overloaded with that meaning +- **Assign on release, not press**: Prevents the join button from also being read as a confirm by the same-frame `ReadToPad`. Costs ~30ms of perceived latency on a tap — acceptable for POC +- **Input Editor UX**: Press-to-join checkbox sits next to the per-device-per-port checkboxes. When on, those checkboxes are disabled. Unchecking press-to-join gives immediate manual control +- **Controllers do unpredictable things**: This is why press-to-join only operates during the explicit join phase (character select), never during gameplay. No silent reassignment during races + +## POC Limitations / Known Gaps + +Not blockers for the POC, but flagged for any path to merge: + +- **Per-port defaults** is a hardcoded library-layer loop (Part 8). Wants a proper API for games. +- **Keyboard release-edge** isn't implemented; if a user has keyboard mapped to multiple ports, they could race-claim two ports at once. `GetLastScancode` is event-based so the press-side case is usually fine. +- **Controller "stolen input" mystery** observed once, not reproducible. Suspected to be keyboard multi-port or a race condition in refresh timing. +- **Analog stick feel on release-edge:** user may find stick-deflect-as-join awkward. Revisit if it does. +- **Disconnect-during-race** leaves the kart idle (Part 7). Acceptable; notification UI is a future nicety. + +## Build Note + +Configure with `-DCMAKE_BUILD_TYPE=Debug` (or Release) — a bare `cmake -B build` without a build type triggers `-Wincompatible-pointer-types` errors in `courses/*/course_offsets.c` on GCC 15. The course offset files declare `const Gfx*` arrays initialized from `const char*` generated symbols, which is a warning under default flags but becomes an error without an explicit build type on this GCC version. + +``` +cmake -DCMAKE_BUILD_TYPE=Debug -B build -G Ninja +cmake --build build --target Spaghettify +``` + +## Verification + +1. Delete `build/spaghettify.cfg.json` to simulate fresh install so Part 8 defaults apply to ports 1-3. +2. Build SpaghettiKart. +3. Connect 2+ controllers. +4. Select 2P mode — verify transition to character select with two greyed-out cursors (frames and number badges still visible, just desaturated). +5. Press and release a button on controller 1 — verify it claims P1 (port 0), cursor lights up to full color, and the release press does NOT also confirm a character. +6. Press and release a button on controller 2 — verify it claims P2 (port 1) and gets a cursor. +7. Both players select characters — verify game proceeds to race. +8. Disconnect controller 2 mid-race — verify kart sits idle, no automatic reassignment. +9. Back to main menu — verify all controllers work normally again (all on port 0). This is the `StopMultiplayer` ignore-list wipe at work. +10. Disconnect during character select — verify port is freed and can be re-joined. +11. Uncheck press-to-join in Input Editor — verify multiplayer still works via manual assignment. +12. Verify checkbox only appears when `ENABLE_PRESS_TO_JOIN` is ON. diff --git a/libultraship b/libultraship index 4eadaf990d..0b8e8476e0 160000 --- a/libultraship +++ b/libultraship @@ -1 +1 @@ -Subproject commit 4eadaf990d234148a250234eb44f3b01f6a3af90 +Subproject commit 0b8e8476e0b8b083eb48c69cbdcb99de75b84192 diff --git a/src/menu_items.c b/src/menu_items.c index da6df96689..588cca38ab 100644 --- a/src/menu_items.c +++ b/src/menu_items.c @@ -7109,12 +7109,21 @@ void func_800A10CC(MenuItem* arg0) { void render_cursor_player(MenuItem* arg0, s32 arg1, s32 arg2) { RGBA16* temp_v1; + s32 useGrayscale = (gPlayerCount >= 2) && CVarGetInteger("gPressToJoinEnabled", 1) && + MultiplayerGetPortStatus(arg1) != 1; temp_v1 = &D_800E74A8[arg1]; gDPSetPrimColor(gDisplayListHead++, 0, 0, temp_v1->red, temp_v1->green, temp_v1->blue, temp_v1->alpha); gDPSetEnvColor(gDisplayListHead++, arg2, arg2, arg2, 0x00); + if (useGrayscale) { + gDPSetGrayscaleColor(gDisplayListHead++, 0xFF, 0xFF, 0xFF, 0xFF); + gSPGrayscale(gDisplayListHead++, true); + } gDisplayListHead = render_menu_textures( gDisplayListHead, gMenuTexturesBorderPlayer[arg1], arg0->column, arg0->row); + if (useGrayscale) { + gSPGrayscale(gDisplayListHead++, false); + } } void func_800A12BC(MenuItem* arg0, MenuTexture* arg1) { diff --git a/src/menus.c b/src/menus.c index d9b84c0a93..acc2389880 100644 --- a/src/menus.c +++ b/src/menus.c @@ -1511,6 +1511,9 @@ void main_menu_act(struct Controller* controller, u16 controllerIdx) { newMode = gGameModePlayerSelection[gPlayerCount - 1][gGameModeMenuColumn[gPlayerCount - 1]]; } else if (btnAndStick & A_BUTTON) { // L800B33D8 + if (gPlayerCount >= 2) { + MultiplayerStart(gPlayerCount); + } func_8009E1C0(); play_sound2(SOUND_MENU_OK_CLICKED); setup_game_mode_selected(); @@ -1573,12 +1576,26 @@ void player_select_menu_act(struct Controller* controller, u16 controllerIdx) { btnAndStick |= A_BUTTON; } + if (controllerIdx == PLAYER_ONE && gPlayerCount >= 2 && + CVarGetInteger("gPressToJoinEnabled", 1)) { + static s8 sPrevPortStatus[MAXCONTROLLERS] = {0}; + s8 p; + for (p = 0; p < gPlayerCount; p++) { + s8 status = MultiplayerGetPortStatus(p); + if (status == 1 && sPrevPortStatus[p] != 1) { + play_sound2(0x49008000); + } + sPrevPortStatus[p] = status; + } + } + if (!is_screen_being_faded()) { switch (gPlayerSelectMenuSelection) { case PLAYER_SELECT_MENU_MAIN: { savedSelection = gCharacterGridSelections[controllerIdx]; if (savedSelection == 0) { if (btnAndStick & B_BUTTON) { + MultiplayerStop(); func_8009E208(); play_sound2(0x49008002); } @@ -1590,6 +1607,7 @@ void player_select_menu_act(struct Controller* controller, u16 controllerIdx) { gCharacterGridIsSelected[controllerIdx] = false; play_sound2(SOUND_MENU_GO_BACK); } else { + MultiplayerStop(); func_8009E208(); play_sound2(0x49008002); } @@ -1602,10 +1620,18 @@ void player_select_menu_act(struct Controller* controller, u16 controllerIdx) { } // L800B36F4 selected = false; - for (i = 0; i < ARRAY_COUNT(gCharacterGridSelections); i++) { - if ((gCharacterGridSelections[i] != 0) && (gCharacterGridIsSelected[i] == 0)) { - selected = true; - break; + { + s32 pressToJoinActive = (gPlayerCount >= 2) && + CVarGetInteger("gPressToJoinEnabled", 1); + for (i = 0; i < ARRAY_COUNT(gCharacterGridSelections); i++) { + if ((gCharacterGridSelections[i] != 0) && (gCharacterGridIsSelected[i] == 0)) { + selected = true; + break; + } + if (pressToJoinActive && i < gPlayerCount && MultiplayerGetPortStatus(i) != 1) { + selected = true; + break; + } } } // L800B3738 @@ -1729,6 +1755,7 @@ void player_select_menu_act(struct Controller* controller, u16 controllerIdx) { break; } if (btnAndStick & A_BUTTON) { + MultiplayerStopPressToJoin(); func_8009E1C0(); play_sound2(0x49008016); func_8000F124(); @@ -1782,6 +1809,7 @@ void course_select_menu_act(struct Controller* controller, u16 controllerIdx) { gCurrentCourseId = gCupCourseOrder[gCupSelection][gCourseIndexInCup]; TrackBrowser_SetTrackFromCup(); if ((btnAndStick & B_BUTTON) != 0) { + MultiplayerStartPressToJoin(); func_8009E208(); play_sound2(SOUND_MENU_GO_BACK); } else if ((btnAndStick & A_BUTTON) != 0) { @@ -1820,6 +1848,7 @@ void course_select_menu_act(struct Controller* controller, u16 controllerIdx) { if (gSubMenuSelection == SUB_MENU_MAP_SELECT_COURSE) { gSubMenuSelection = SUB_MENU_MAP_SELECT_CUP; } else { + MultiplayerStartPressToJoin(); func_8009E208(); } reset_cycle_flash_menu();