switch-pico/tests/switch_native_haptics_test.cpp

323 lines
18 KiB
C++

#include "usb/switch/switch_native_haptics.h"
#include "profile/controller_profile.h"
#include "profile/controller_profile_transform.h"
#include <cstdlib>
#include <cstring>
#include <iostream>
namespace {
constexpr uint8_t kNeutral[8] = {0x00, 0x01, 0x40, 0x40, 0x00, 0x01, 0x40, 0x40};
constexpr uint8_t kSeed[8] = {0x00, 0x21, 0x40, 0x48, 0x00, 0x01, 0x40, 0x40};
constexpr uint8_t kOne[8] = {0x00, 0x00, 0x10, 0x69, 0x00, 0x01, 0x40, 0x40};
constexpr uint8_t kTwo[8] = {0x00, 0x74, 0x1c, 0xa9, 0x00, 0x01, 0x40, 0x40};
constexpr uint8_t kThree[8] = {0x78, 0x77, 0x1c, 0xe9, 0x00, 0x01, 0x40, 0x40};
void require(bool condition, const char* message) {
if (!condition) {
std::cerr << message << '\n';
std::exit(1);
}
}
void expect_packet(const SwitchNativeHapticsPackets& packets, const uint8_t expected[8],
const char* message) {
require(packets.count == 1 && std::memcmp(packets.bytes[0], expected, 8) == 0, message);
}
ControllerRumbleOutput single(SwitchHapticsSample left, SwitchHapticsSample right = {}) {
ControllerRumbleOutput output{};
output.hd.actuators[0].sample_count = 1;
output.hd.actuators[1].sample_count = 1;
output.hd.actuators[0].samples[0] = left;
output.hd.actuators[1].samples[0] = right;
return output;
}
ControllerRumbleOutput play(SwitchHapticsDecoder& device, const SwitchNativeHapticsPackets& packets) {
require(packets.count >= 1 && packets.count <= 2, "encoder exceeded bounded packet schedule");
ControllerRumbleOutput result{};
for (uint8_t i = 0; i < packets.count; ++i) {
result = device.decode(packets.bytes[i]);
for (const auto& side : result.hd.actuators) {
require(side.sample_count >= 1 && side.sample_count <= 3, "invalid generated substep count");
for (uint8_t step = 0; step < side.sample_count; ++step) {
const auto& sample = side.samples[step];
require(sample.low_amplitude_q15 <= 17867 && sample.high_amplitude_q15 <= 17867,
"generated an amplitude above documented absolute code 100");
require(sample.low_frequency_index >= 1 && sample.low_frequency_index <= 127 &&
sample.high_frequency_index >= 1 && sample.high_frequency_index <= 127,
"generated frequency outside public absolute range");
}
}
}
return result;
}
void expect_sample(const SwitchHapticsSample& actual, const SwitchHapticsSample& expected) {
require(actual.low_frequency_index == expected.low_frequency_index &&
actual.high_frequency_index == expected.high_frequency_index &&
actual.low_amplitude_q15 == expected.low_amplitude_q15 &&
actual.high_amplitude_q15 == expected.high_amplitude_q15,
"native output lost band frequency/amplitude or substep order");
}
void test_public_absolute_goldens() {
// Independent dekuNukem rumble_data_table.md byte example: HF=0x1a8,
// HA=0x88, LF=0x63, LA=0x804d => a8 89 e3 4d. The input amplitudes are
// this project's normalized LUT values, not the public physical amplitudes.
SwitchNativeHapticsEncoder encoder;
constexpr uint8_t expected[8] = {0xa8, 0x89, 0xe3, 0x4d, 0x80, 0x00, 0x40, 0x52};
const auto output = encoder.encode(single({99, 106, 3371, 8933}, {64, 32, 4467, 0}), false, false);
expect_packet(output, expected, "public absolute vector/band-actuator isolation mismatch");
require(!output.raw && !output.quantized, "exact absolute vector was changed");
expect_packet(encoder.encode({}, false, false), kNeutral, "conventional zero was not exact neutral");
constexpr uint8_t safe_max[8] = {0x00, 0xc9, 0x40, 0x72, 0x00, 0xc9, 0x40, 0x72};
expect_packet(encoder.encode({255, 255}, false, true), safe_max,
"conventional maximum did not map to safe fixed carriers");
const auto clamped = encoder.encode(single({64, 64, 32767, 32767}, {64, 64, 32767, 32767}), false, true);
expect_packet(clamped, safe_max, "unsafe HD amplitude escaped wire code100 clamp");
require(clamped.quantized && !clamped.raw, "safety clamp was not observable");
}
void test_provenance_and_profile_gains() {
SwitchHapticsDecoder host;
SwitchNativeHapticsEncoder encoder;
const ControllerRumbleOutput conventional{33, 71};
require(!conventional.raw_valid && !conventional.raw_unmodified,
"conventional rumble acquired Nintendo wire provenance");
require(!host.decode(nullptr).raw_valid, "missing payload acquired raw provenance");
constexpr uint8_t max_left[8] = {0x00, 0xc9, 0x40, 0x72, 0x00, 0x01, 0x40, 0x40};
const auto decoded = host.decode(max_left);
require(decoded.raw_valid && decoded.raw_unmodified && std::memcmp(decoded.raw, max_left, 8) == 0,
"Switch decode did not preserve original bytes and provenance");
ControllerProfile profile{};
profile.strong_rumble_scale = 255;
profile.weak_rumble_scale = 255;
const auto unity = controller_profile_scale_host_rumble(decoded, profile);
const auto raw = encoder.encode(unity, false, true);
expect_packet(raw, max_left, "unity did not preserve independently specified wire bytes");
require(raw.raw, "safe synchronized unity did not use raw path");
profile.strong_rumble_scale = 64;
profile.weak_rumble_scale = 128;
const auto scaled = controller_profile_scale_host_rumble(decoded, profile);
require(scaled.raw_valid && !scaled.raw_unmodified, "profile gains failed to revoke raw fast-path permission");
constexpr uint8_t intermediate[8] = {0x00, 0x89, 0x40, 0x52, 0x00, 0x01, 0x40, 0x40};
const auto intermediate_packets = encoder.encode(scaled, false, true);
expect_packet(intermediate_packets, intermediate, "intermediate band gains treated Q15 as wire amplitude");
require(!intermediate_packets.raw && intermediate_packets.quantized,
"intermediate Q15 rounding was not reported");
profile.strong_rumble_scale = 255;
profile.weak_rumble_scale = 255;
require(!controller_profile_scale_host_rumble(scaled, profile).raw_unmodified,
"later unity gain restored revoked raw provenance");
profile.strong_rumble_scale = 0;
profile.weak_rumble_scale = 0;
expect_packet(encoder.encode(controller_profile_scale_host_rumble(decoded, profile), false, true),
kNeutral, "zero gains did not produce exact silence");
}
ControllerRumbleOutput sequence(uint8_t count) {
auto output = single({65, 65, 2139, 2282});
auto& side = output.hd.actuators[0];
side.sample_count = count;
side.samples[1] = {65, 66, 2139, 2093};
side.samples[2] = {65, 66, 2093, 2093};
return output;
}
void test_compressed_goldens_and_resynchronization() {
// Independently hand-packed command indices from existing protocol forms:
// H=[+4/+1Hz, -4/+1Hz, hold], L=[+1/+1Hz, hold, -1/hold].
// These defend bit placement/order independently of decoder round trips;
// they do not claim physical-controller acceptance of compressed forms.
const uint8_t* goldens[3] = {kOne, kTwo, kThree};
for (uint8_t count = 1; count <= 3; ++count) {
SwitchNativeHapticsEncoder encoder;
encoder.encode(single({64, 64, 2093, 2093}), false, false);
const auto packets = encoder.encode(sequence(count), false, false);
expect_packet(packets, goldens[count - 1], "compressed one/two/three-step golden mismatch");
require(!packets.quantized, "representable ordered substeps were quantized");
}
SwitchHapticsDecoder host;
SwitchNativeHapticsEncoder encoder;
encoder.encode(host.decode(kSeed), false, true);
const auto commands = host.decode(kThree);
const auto forwarded = encoder.encode(commands, false, true);
expect_packet(forwarded, kThree, "synchronized compressed unity was not exact");
require(forwarded.raw, "safe synchronized compressed unity did not use raw");
const auto repeated = host.decode(kThree);
require(repeated.hd.actuators[0].sample_count == 1, "host repeat did not retain endpoint");
require(encoder.encode(repeated, false, true).raw, "same-word hold unexpectedly lost synchronization");
encoder.reset();
SwitchHapticsDecoder device;
const auto recovery = encoder.encode(commands, false, true);
require(!recovery.raw && recovery.count == 2 && !recovery.quantized,
"dropped history did not trigger exact baseline recovery");
const auto result = play(device, recovery);
require(result.hd.actuators[0].sample_count == 3, "recovery collapsed three substeps");
for (uint8_t step = 0; step < 3; ++step) expect_sample(result.hd.actuators[0].samples[step], sequence(3).hd.actuators[0].samples[step]);
// A stale raw envelope after changed profile/output must be compared with
// physical state, not trusted solely because its flags still say unity.
encoder.encode(single({64, 64, 4467, 0}), false, false);
require(!encoder.encode(repeated, false, true).raw, "raw reuse ignored changed physical state");
}
void test_absolute_plus_commands_and_selected_coordinate() {
SwitchNativeHapticsEncoder encoder;
SwitchHapticsDecoder host;
SwitchHapticsDecoder device;
play(device, encoder.encode(host.decode(kSeed), false, true));
// Type 4: H absolute code32/frequency70; L command20, then H24/L17.
constexpr uint8_t type4[8] = {0x8d, 0x38, 0x52, 0x90, 0x00, 0x01, 0x40, 0x40};
const auto desired = host.decode(type4);
const auto encoded = encoder.encode(desired, false, false);
require(encoded.count == 1, "representable mixed form needed extra packets");
const auto actual = play(device, encoded);
require(actual.hd.actuators[0].sample_count == 2, "mixed form lost a substep");
expect_sample(actual.hd.actuators[0].samples[0], {65, 70, 2139, 4096});
expect_sample(actual.hd.actuators[0].samples[1], {66, 70, 2332, 4096});
require(!encoded.quantized, "representable mixed absolute/relative form was quantized");
// Preserve non-absolute low amplitude index134 while updating only H freq.
constexpr uint8_t selected[8] = {0x07, 0x00, 0x00, 0x68, 0x00, 0x01, 0x40, 0x40};
const auto frequency = host.decode(selected);
const auto selected_packets = encoder.encode(frequency, false, false);
require(selected_packets.count == 1, "selected coordinate needed extra packets");
const auto selected_result = play(device, selected_packets);
expect_sample(selected_result.hd.actuators[0].samples[0], {66, 80, 2332, 4096});
}
void test_relative_only_amplitude_survives_prefixes() {
SwitchNativeHapticsEncoder encoder;
SwitchHapticsDecoder host;
SwitchHapticsDecoder device;
// Reach internal index2 through two distinct increment words. It is below
// the first nonzero absolute code (index15), yet is a legal relative state.
constexpr uint8_t first[8] = {0x00, 0x00, 0x50, 0x6b, 0x00, 0x01, 0x40, 0x40};
constexpr uint8_t second[8] = {0x00, 0x00, 0x50, 0x69, 0x00, 0x01, 0x40, 0x40};
play(device, encoder.encode(host.decode(first), false, true));
play(device, encoder.encode(host.decode(second), false, true));
auto input = single({80, 64, 134, 134});
input.hd.actuators[0].sample_count = 3;
input.hd.actuators[0].samples[1] = {81, 64, 137, 134};
input.hd.actuators[0].samples[2] = {82, 64, 140, 134};
const auto packets = encoder.encode(input, false, false);
constexpr uint8_t frequency_prefix[4] = {0x06, 0x00, 0x00, 0x68};
require(packets.count == 2 && !packets.quantized &&
std::memcmp(packets.bytes[0], frequency_prefix, 4) == 0,
"single-coordinate prefix rounded an existing relative-only amplitude");
const auto result = play(device, packets);
require(result.hd.actuators[0].sample_count == 3, "state prefix lost temporal slots");
for (uint8_t step = 0; step < 3; ++step)
expect_sample(result.hd.actuators[0].samples[step], input.hd.actuators[0].samples[step]);
// The right side now needs an absolute baseline; its partner's relative
// amplitude must not be rounded just to make both setup words absolute.
input.hd.actuators[0].samples[0] = {82, 64, 140, 134};
input.hd.actuators[0].samples[1] = {82, 64, 143, 134};
input.hd.actuators[0].samples[2] = {83, 64, 146, 134};
input.hd.actuators[1] = sequence(3).hd.actuators[0];
const auto partner_recovery = encoder.encode(input, false, false);
const auto partner_result = play(device, partner_recovery);
require(partner_recovery.count == 2 && !partner_recovery.quantized,
"partner recovery quantized an independently representable side");
for (uint8_t side = 0; side < 2; ++side)
for (uint8_t step = 0; step < 3; ++step)
expect_sample(partner_result.hd.actuators[side].samples[step], input.hd.actuators[side].samples[step]);
// Resync from established neutral must distinguish internal silent index1
// from absolute zero, or the representable first index2 step gets rounded.
encoder.reset();
device.reset();
input = single({64, 64, 134, 0});
input.hd.actuators[0].sample_count = 3;
input.hd.actuators[0].samples[1] = {65, 64, 137, 0};
input.hd.actuators[0].samples[2] = {66, 64, 140, 0};
const auto relative_recovery = encoder.encode(input, false, false);
const auto recovered = play(device, relative_recovery);
require(relative_recovery.count == 2 && !relative_recovery.quantized,
"relative-only predecessor was lost during recovery");
for (uint8_t step = 0; step < 3; ++step)
expect_sample(recovered.hd.actuators[0].samples[step], input.hd.actuators[0].samples[step]);
encoder.reset();
device.reset();
const auto collision = encoder.encode(single({66, 64, 134, 0}), false, false);
const auto collision_result = play(device, collision);
require(collision.count == 2 && !collision.quantized &&
std::memcmp(collision.bytes[0], collision.bytes[1], 4) != 0,
"same-word prefix collision suppressed a representable relative increment");
expect_sample(collision_result.hd.actuators[0].samples[0], {66, 64, 134, 0});
}
void test_mono_bands_ties_and_temporal_policy() {
SwitchNativeHapticsEncoder encoder;
auto input = single({32, 80, 4467, 2093}, {96, 100, 2093, 8933});
constexpr uint8_t mono[8] = {0x90, 0x89, 0x20, 0x52, 0x90, 0x89, 0x20, 0x52};
expect_packet(encoder.encode(input, true, true), mono, "mono did not independently select dominant bands");
input.hd.actuators[1].samples[0].low_amplitude_q15 = 4467;
expect_packet(encoder.encode(input, true, true), mono, "mono tie did not retain left band frequency");
// Dominance is evaluated BEFORE LUT rounding: 4468 > 4467 although both
// round to the same safe amplitude. The right low-band frequency must win.
input.hd.actuators[1].samples[0].low_amplitude_q15 = 4468;
constexpr uint8_t near_tie[8] = {0x90, 0x89, 0x60, 0x52, 0x90, 0x89, 0x60, 0x52};
expect_packet(encoder.encode(input, true, true), near_tie, "LUT rounding changed mono dominance");
encoder.reset();
SwitchHapticsDecoder device;
input = sequence(3);
input.hd.actuators[1].samples[0] = {65, 66, 0, 4467};
const auto packets = encoder.encode(input, true, false);
const auto result = play(device, packets);
require(packets.quantized && result.hd.actuators[0].sample_count == 3 &&
result.hd.actuators[1].sample_count == 3,
"unequal-side temporal quantization was hidden or dropped slots");
for (uint8_t step = 0; step < 3; ++step) {
const auto expected = SwitchHapticsSample{65, 66, sequence(3).hd.actuators[0].samples[step].low_amplitude_q15, 4467};
expect_sample(result.hd.actuators[0].samples[step], expected);
expect_sample(result.hd.actuators[1].samples[step], expected);
}
}
void test_unrepresentable_timeline_and_safety() {
SwitchNativeHapticsEncoder encoder;
SwitchHapticsDecoder device;
auto input = single({64, 64, 17867, 0});
auto& left = input.hd.actuators[0];
left.sample_count = 3;
left.samples[1] = {127, 1, 0, 17867};
left.samples[2] = {1, 127, 17867, 0};
const auto packets = encoder.encode(input, false, false);
const auto result = play(device, packets);
const auto& actual = result.hd.actuators[0];
require(packets.count == 2 && packets.quantized && actual.sample_count == 3,
"unrepresentable sequence did not expose bounded three-slot quantization");
require(actual.samples[0].low_amplitude_q15 == 17867 && actual.samples[0].high_amplitude_q15 == 0 &&
actual.samples[1].low_amplitude_q15 == 0 && actual.samples[1].high_amplitude_q15 > 0 &&
actual.samples[2].low_amplitude_q15 > 0 && actual.samples[2].high_amplitude_q15 == 0,
"quantization collapsed band transitions or introduced sound into a zero band");
SwitchHapticsDecoder host;
// Unsafe host command substitute240 briefly peaks then stops; checking only
// final endpoint would mistakenly authorize the unsafe raw packet.
constexpr uint8_t unsafe_steps[8] = {0x21, 0x84, 0x10, 0xc4, 0x00, 0x01, 0x40, 0x40};
const auto unsafe = encoder.encode(host.decode(unsafe_steps), false, true);
require(!unsafe.raw && unsafe.quantized, "unsafe intermediate raw amplitude passed through");
play(device, unsafe);
constexpr uint8_t reserved[8] = {0x01, 0x00, 0x00, 0x40, 0x00, 0x01, 0x40, 0x40};
require(!encoder.encode(host.decode(reserved), false, true).raw,
"reserved discriminator was passed through as a qualified native form");
}
} // namespace
int main() {
test_public_absolute_goldens();
test_provenance_and_profile_gains();
test_compressed_goldens_and_resynchronization();
test_absolute_plus_commands_and_selected_coordinate();
test_relative_only_amplitude_survives_prefixes();
test_mono_bands_ties_and_temporal_policy();
test_unrepresentable_timeline_and_safety();
return 0;
}