Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@ bridge state, and mapped into XInput/HID trigger bytes for trigger-like buttons.
Plain binary presses on trigger-like buttons, such as `press("RT", 0)`, map to a
full analog pull.

Timed actions reject negative `durationMs` values. A zero duration remains
persistent where the action API uses zero-duration input, while positive values
request a timed hold. The requested SDK duration is not a guarantee of how long
the target game observes the input or how many frames it spans.

Direct D-pad button presses also keep the structured D-pad state synchronized:

```ts
Expand Down
90 changes: 90 additions & 0 deletions packages/core/src/__tests__/negative-duration-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, spyOn, test } from "bun:test";
import { createActionMap, createController, DryRunAdapter } from "../index";

describe("negative action duration validation", () => {
test("rejects negative durations before adapter output across command paths", async () => {
const adapter = new DryRunAdapter();
const controller = await createController({
profile: "xbox",
adapter,
replay: false,
});
const initialHistoryLength = adapter.history.length;
const initialStateHistoryLength = adapter.stateHistory.length;
const neutral = spyOn(adapter, "neutral");

await expect(controller.press("A", -1)).rejects.toThrow(
"must not be negative",
);
await expect(controller.trigger("RT", 1, -1)).rejects.toThrow(
"must not be negative",
);

const negativeCommands = [
{ type: "press", button: "A", durationMs: -1 },
{ type: "stick", stick: "LEFT", x: 1, y: 0, durationMs: -1 },
{ type: "trigger", trigger: "RT", value: 1, durationMs: -1 },
{ type: "dpad", direction: "UP", durationMs: -1 },
{ type: "combo", buttons: ["A", "B"], durationMs: -1 },
{ type: "touchpad", pressed: true, durationMs: -1 },
{ type: "motion", durationMs: -1 },
] as const;

for (const command of negativeCommands) {
await expect(controller.sequence([command])).rejects.toThrow(
"must not be negative",
);
}

await expect(
controller.sequence([
{ type: "press", button: "A", durationMs: 0 },
{
type: "sequence",
commands: [
{ type: "trigger", trigger: "RT", value: 1, durationMs: -1 },
],
},
]),
).rejects.toThrow("must not be negative");

const actions = createActionMap(controller, {
attack: [{ type: "press", button: "A", durationMs: 50 }],
});
await expect(actions.run("attack", { durationMs: -1 })).rejects.toThrow(
"must not be negative",
);

expect(adapter.history).toHaveLength(initialHistoryLength);
expect(adapter.stateHistory).toHaveLength(initialStateHistoryLength);
expect(neutral).not.toHaveBeenCalled();
neutral.mockRestore();
await controller.disconnect();
});

test("keeps zero-duration input persistent and positive durations timed", async () => {
const adapter = new DryRunAdapter();
const controller = await createController({
profile: "xbox",
adapter,
replay: false,
});

await controller.press("A", 0);
expect(controller.getState().buttons.A).toBe(true);

await controller.trigger("RT", 0.5, 0);
expect(controller.getState().analogButtons.RT).toBe(0.5);

await controller.trigger("RT", 1, 1);
expect(controller.getState().analogButtons.RT).toBe(0);
expect(adapter.history.map((entry) => entry.command.type)).toEqual([
"press",
"trigger",
"trigger",
"trigger",
]);

await controller.disconnect();
});
});
1 change: 1 addition & 0 deletions packages/core/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export class ControllerRuntime {
context: CommandContext = {},
signal?: AbortSignal,
): Promise<void> {
this.safety.assertNonNegativeDurations(command);
const timedPressSignal = isPositiveTimedPress(command) ? signal : undefined;
if (timedPressSignal?.aborted) {
throw new TimedPressAbortError({
Expand Down
23 changes: 21 additions & 2 deletions packages/core/src/safety.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,31 @@ export class SafetyGuard {
}

assert(command: ControllerCommand): void {
this.assertNonNegativeDurations(command);
this.assertRateLimit();
this.assertDurations(command);
this.assertDisabledControls(command);
this.assertInputLoop(command);
}

assertNonNegativeDurations(command: ControllerCommand): void {
if (
"durationMs" in command &&
command.durationMs !== undefined &&
command.durationMs < 0
) {
throw new SafetyError(
`Action duration ${command.durationMs}ms must not be negative`,
);
}

if (command.type === "sequence") {
for (const child of command.commands) {
this.assertNonNegativeDurations(child);
}
}
}

private assertRateLimit(): void {
const now = Date.now();
this.commandTimestamps.push(now);
Expand All @@ -88,7 +107,7 @@ export class SafetyGuard {
(command.type === "press" ||
command.type === "combo" ||
command.type === "dpad") &&
command.durationMs
command.durationMs !== undefined
) {
if (command.durationMs > this.config.maxButtonHoldMs) {
throw new SafetyError(
Expand All @@ -101,7 +120,7 @@ export class SafetyGuard {
(command.type === "stick" ||
command.type === "touchpad" ||
command.type === "motion") &&
command.durationMs
command.durationMs !== undefined
) {
if (command.durationMs > this.config.maxStickHoldMs) {
throw new SafetyError(
Expand Down
Loading