feat: ccos emulator

This commit is contained in:
2026-01-28 18:08:11 +01:00
parent ee8d400ad7
commit 16bf766de9
9 changed files with 178 additions and 41 deletions

View File

@@ -57,6 +57,7 @@ const config = {
"graphic_eq",
"mail",
"calculate",
"playground_2",
"open_in_browser",
"chevron_backward",
"chevron_forward",

View File

@@ -1,26 +1,35 @@
import type { Attachment } from "svelte/attachments";
import { browser } from "$app/environment";
import { persistentWritable } from "$lib/storage";
import type { CharaDevice } from "$lib/serial/device";
import type { CCOS, CCOSKeyboardEvent } from "./ccos";
import type { ReplayRecorder } from "$lib/charrecorder/core/recorder";
export const emulatedCCOS = persistentWritable("emulatedCCOS", false);
export function ccosKeyInterceptor() {
return ((element: Window) => {
const ccos = browser
? import("./ccos").then((module) => module.fetchCCOS(".test"))
: Promise.resolve(undefined);
export function ccosKeyInterceptor(
port: CharaDevice | undefined,
recorder: ReplayRecorder,
) {
return ((element: HTMLElement) => {
const ccos =
port?.port && "handleKeyEvent" in port?.port
? (port.port as CCOS)
: undefined;
console.log("Attaching CCOS key interceptor", ccos);
function onEvent(event: KeyboardEvent) {
ccos.then((it) => it?.handleKeyEvent(event));
ccos?.handleKeyEvent(event);
if (!event.defaultPrevented) {
recorder.next(event);
}
}
element.addEventListener("keydown", onEvent, true);
element.addEventListener("keyup", onEvent, true);
if (ccos) {
element.addEventListener("keydown", onEvent, true);
element.addEventListener("keyup", onEvent, true);
element.add;
}
return () => {
ccos.then((it) => it?.destroy());
element.removeEventListener("keydown", onEvent, true);
element.removeEventListener("keyup", onEvent, true);
};
}) satisfies Attachment<Window>;
}) satisfies Attachment<HTMLElement>;
}

View File

@@ -1,7 +1,6 @@
import { getMeta } from "$lib/meta/meta-storage";
import type { SerialPortLike } from "$lib/serial/device";
import type {
CCOSInEvent,
CCOSInitEvent,
CCOSKeyPressEvent,
CCOSKeyReleaseEvent,
@@ -11,7 +10,7 @@ import { KEYCODE_TO_SCANCODE, SCANCODE_TO_KEYCODE } from "./ccos-interop";
const device = "zero_wasm";
class CCOSKeyboardEvent extends KeyboardEvent {
export class CCOSKeyboardEvent extends KeyboardEvent {
constructor(...params: ConstructorParameters<typeof KeyboardEvent>) {
super(...params);
}
@@ -26,7 +25,46 @@ const MASK_GUI = 0b1000_1000;
export class CCOS implements SerialPortLike {
private readonly currKeys = new Set<number>();
private readonly layout = new Map<string, string>();
private readonly layout = new Map<string, string>([
...Array.from(
{ length: 26 },
(_, i) =>
[
JSON.stringify([`Key${String.fromCharCode(65 + i)}`, "Shift"]),
String.fromCharCode(65 + i),
] as const,
),
...Array.from(
{ length: 10 },
(_, i) => [JSON.stringify([`Key${i}`]), i.toString()] as const,
),
[JSON.stringify(["Space"]), " "],
[JSON.stringify(["Backquote"]), "`"],
[JSON.stringify(["Minus"]), "-"],
[JSON.stringify(["Comma"]), ","],
[JSON.stringify(["Period"]), "."],
[JSON.stringify(["Semicolon"]), ";"],
[JSON.stringify(["Equal"]), "="],
[JSON.stringify(["Backquote", "Shift"]), "~"],
[JSON.stringify(["Minus", "Shift"]), "_"],
[JSON.stringify(["Comma", "Shift"]), "<"],
[JSON.stringify(["Period", "Shift"]), ">"],
[JSON.stringify(["Semicolon", "Shift"]), ":"],
[JSON.stringify(["Equal", "Shift"]), "+"],
[JSON.stringify(["Digit0", "Shift"]), ")"],
[JSON.stringify(["Digit1", "Shift"]), "!"],
[JSON.stringify(["Digit2", "Shift"]), "@"],
[JSON.stringify(["Digit3", "Shift"]), "#"],
[JSON.stringify(["Digit4", "Shift"]), "$"],
[JSON.stringify(["Digit5", "Shift"]), "%"],
[JSON.stringify(["Digit6", "Shift"]), "^"],
[JSON.stringify(["Digit7", "Shift"]), "&"],
[JSON.stringify(["Digit8", "Shift"]), "*"],
[JSON.stringify(["Digit9", "Shift"]), "("],
]);
private readonly worker = new Worker("/ccos-worker.js", { type: "module" });
@@ -126,7 +164,6 @@ export class CCOS implements SerialPortLike {
this.controller?.enqueue(event.data);
return;
}
console.log("CCOS worker message", event.data);
switch (event.data.type) {
case "ready": {
this.resolveReady();
@@ -220,7 +257,7 @@ export class CCOS implements SerialPortLike {
}
export async function fetchCCOS(
version = ".2.2.0-beta.12+266bdda",
version = "3.0.0-rc.0",
fetch: typeof window.fetch = window.fetch,
): Promise<CCOS | undefined> {
const meta = await getMeta(device, version, fetch);

View File

@@ -147,7 +147,7 @@ export class CharaDevice {
version!: string;
company!: "CHARACHORDER" | "FORGE";
device!: "ONE" | "TWO" | "LITE" | "X" | "M4G" | "ENGINE" | "ZERO";
chipset!: "M0" | "S2" | "S3";
chipset!: "M0" | "S2" | "S3" | "WASM";
keyCount!: 90 | 67 | 256;
layerCount = 3;
profileCount = 1;
@@ -157,7 +157,7 @@ export class CharaDevice {
}
constructor(
private readonly port: SerialPortLike,
readonly port: SerialPortLike,
public baudRate = 115200,
) {}

View File

@@ -46,15 +46,6 @@
element?.closest<HTMLElement>("[popover]")?.hidePopover();
}
async function connectCC0(event: MouseEvent) {
const { fetchCCOS } = await import("$lib/ccos/ccos");
closePopover();
const ccos = await fetchCCOS();
if (ccos) {
connect(ccos, !event.shiftKey);
}
}
async function connectDevice(event: MouseEvent) {
const port = await navigator.serial.requestPort({
filters: event.shiftKey ? [] : [...PORT_FILTERS.values()],

View File

@@ -116,6 +116,25 @@
{/if}
</div>
<ul>
<li>
<a
href={import.meta.env.VITE_DISCORD_URL}
rel="noreferrer"
target="_blank"
>
<svg
class="discord-icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 126.64 96"
>
<path
fill="currentColor"
d="m81 0-3 7Q63 4 49 7l-4-7-26 8Q-4 45 1 80q14 10 32 16l6-11-10-5 2-2q33 13 64 0l3 2-11 5 7 11q17-5 32-16 4-40-19-72-12-5-26-8M42 65q-10-1-11-12 0-15 11-13c11 2 12 6 12 13q-1 11-12 12m42 0q-10-1-11-12 0-15 11-13c11 2 12 6 12 13q-1 11-12 12"
/></svg
>
Discord</a
>
</li>
<li>
<a href={import.meta.env.VITE_BUGS_URL} rel="noreferrer" target="_blank"
><span class="icon">bug_report</span> Bugs</a
@@ -168,6 +187,11 @@
$sync-border-radius: 16px;
.discord-icon {
margin: 5px;
inline-size: 14px;
}
.sync-box {
display: flex;
position: relative;

View File

@@ -17,6 +17,11 @@
: []),
],
[
{
href: "/editor/",
icon: "playground_2",
title: "Emulator",
},
{
href: import.meta.env.VITE_LEARN_URL,
icon: "school",

View File

@@ -5,18 +5,17 @@
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
import TrackRollingWpm from "$lib/charrecorder/TrackRollingWpm.svelte";
import { fade } from "svelte/transition";
import { initSerial, serialPort } from "$lib/serial/connection";
import { tick } from "svelte";
import { ccosKeyInterceptor } from "$lib/ccos/attachment";
let recorder: ReplayRecorder = $state(new ReplayRecorder());
let replay: Replay | undefined = $state();
let wpm = $state(0);
let cc0Loading = $state(false);
let chords: InferredChord[] = $state([]);
function handleRawKey(event: KeyboardEvent) {
event.preventDefault();
keyEvent(event);
}
function keyEvent(event: KeyboardEvent) {
if (event.key === "Tab") {
clear();
@@ -47,15 +46,60 @@
a.download = "replay.json";
a.click();
}
async function connectCC0(event: MouseEvent) {
cc0Loading = true;
try {
await tick();
if ($serialPort) {
$serialPort?.close();
$serialPort = undefined;
}
const { fetchCCOS } = await import("$lib/ccos/ccos");
const ccos = await fetchCCOS();
if (ccos) {
try {
await initSerial(ccos, !event.shiftKey);
} catch (error) {
console.error(error);
}
}
} finally {
cc0Loading = false;
}
}
</script>
<svelte:head>
<title>Editor</title>
</svelte:head>
<svelte:window onkeydown={handleRawKey} onkeyup={handleRawKey} />
<section>
<h2>Editor</h2>
<h2>
CCOS Emulator
{#if $serialPort?.chipset === "WASM"}
<small>(Emulator Active)</small>
{:else}
<button class="primary" disabled={cc0Loading} onclick={connectCC0}>
<span class="icon">play_arrow</span>
Boot CCOS Emulator</button
>
{/if}
</h2>
<p style:max-width="600px">
Try a (limited) demo of CCOS running directly in your browser.<br /><span
style:color="var(--md-sys-color-primary)"
>Chording requires an <b>NKRO Keyboard</b> to work properly.</span
>
<br />Browsers usually report key timings with limited accuracy to revent
fingerprinting, which can impact chording.
<br /><i>Results may vary.</i>
<br />
Use sidebar tabs to configure <a href="/config/chords/">Chords</a>,
<a href="/config/layout/">Layout</a>
and <a href="/config/settings/">Settings</a>.
</p>
{#if replay}
<div class="replay" transition:fade={{ duration: 100 }}>
@@ -66,7 +110,9 @@
{#key recorder}
<div
class="editor"
tabindex="-1"
out:fade={{ duration: 100 }}
{@attach ccosKeyInterceptor($serialPort, recorder)}
style:opacity={replay ? 0 : undefined}
>
<CharRecorder replay={recorder.player} cursor={true} keys={true}>
@@ -95,15 +141,38 @@
width: 100%;
}
a {
display: inline;
padding: 0;
color: var(--md-sys-color-primary);
}
small {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--md-sys-color-primary);
font-weight: 500;
font-size: 0.6em;
}
button.primary {
display: inline-flex;
background: none;
color: var(--md-sys-color-primary);
}
.replay,
.editor {
position: absolute;
top: 3em;
left: 0;
transition: opacity 0.1s;
margin: 4px;
outline: 1px solid var(--md-sys-color-outline);
padding: 16px;
padding-bottom: 5em;
padding-left: 0;
&:focus-within {
outline: 2px solid var(--md-sys-color-primary);
}
}
.toolbar {

View File

@@ -26,6 +26,7 @@ process.env["VITE_LATEST_FIRMWARE"] = "1.1.4";
process.env["VITE_STORE_URL"] = "https://www.charachorder.com/";
process.env["VITE_MATRIX_URL"] = "https://charachorder.io/";
process.env["VITE_FIRMWARE_URL"] = "https://charachorder.io/firmware";
process.env["VITE_DISCORD_URL"] = "https://discord.gg/CharaChorder";
export default defineConfig({
build: {