L'adaptateur traduit clavier et manette en actions SEQUA (PlayerInput) ; le Core ne recoit et n'enregistre que ces actions, le rejeu reste exact. - SEQUA 3D : PlayerInput, poll_input(), capacite player_input (interface des adaptateurs en version 2) - protocole Core - rendu 0.2 : render/input, envoye quand les actions changent ; entree remise au neutre si le rendu disparait - Godot : InputMap du projet (ZQSD, fleches, espace, manette) ou script SequaInput.sequa_player_input() pour une correspondance avancee ; entree neutre sans focus ; exemple examples/sequa_input.gd - adaptateur debug : entrees scriptees par numero d'image - Studio : ligne "Entrees du joueur" du panneau Rendu
14 KiB
Spécification : protocole Studio - Core 0.2 et Studio host
- Statut : référence de développement (squelette). Décisions : ADR 0006.
- Le format reste du JSON provisoire (ADR 0005) en attendant l'ADR du schéma.
- Toute ambiguïté se règle en modifiant CE document, pas en interprétant chacun de son côté.
1. Acteurs
| Acteur | Exécutable / dossier | Rôle |
|---|---|---|
| Core | sequa-core (core/) |
Serveur WebSocket, session de simulation en direct, enregistrement, rejeu |
| Host | sequa-studio (studio/host/) |
Lance ou rejoint le Core, le supervise, le recharge par rejeu, sert l'interface |
| UI | studio/ui/ (navigateur) |
Interface ; client WebSocket du Core |
Ports par défaut : Core 9100, interface servie par le host 9180, faux Core de développement 9101, Vite 5173. Écoute sur 127.0.0.1 par défaut partout.
2. Enveloppe et versions
- Enveloppe :
{ "v": 1, "type": "<type>", "id": "<chaîne>"?, "payload": { ... } }.vreste 1. id(facultatif) : identifiant de requête choisi par le client. Toute réponse à une requête qui portait unidrecopie cetid. Une requête sansidreçoit quand même sa réponse, sansid.helloest envoyé sansid(le host et l'UI le font ainsi) ;welcomeet les erreurs de poignée de main n'en portent pas.- Version du protocole :
0.3.0(0.3 ajoute les messages du rendu, §9 ; le reste est inchangé depuis 0.2). Compatibilité inchangée : même majeure et, en 0.x, même mineure. 0.1 et 0.2 sont donc incompatibles. payloadest toujours un objet (éventuellement vide{}).- Les entiers 64 bits (empreintes) sont transmis en chaîne hexadécimale de 16 caractères minuscules, jamais en nombre JSON (perte de précision en JavaScript au-delà de 2^53).
- Les données binaires (enregistrements, tableaux d'empreintes) sont en base64 standard (RFC 4648, avec remplissage
=).
3. Connexion, authentification, origine
À l'ouverture d'une connexion (avant tout message), le Core applique dans cet ordre :
- Jeton : si le Core a été lancé avec
--token <t>, l'URL de connexion doit contenir le paramètre de requêtetoken=<t>(ex.ws://127.0.0.1:9100/?token=ab12...). Comparaison en temps constant. Sinon : messageerror{ code: "unauthorized" }puis fermeture 1008. - Origine : si l'en-tête HTTP
Originest présent (connexion venant d'un navigateur), il doit figurer dans la liste autorisée :- les valeurs passées par
--allowed-origin <origine>(répétable), comparées exactement (schéma, hôte, port ; ex.http://127.0.0.1:9180) ; - si aucune n'est passée : toute origine
http://localhost:<port>ouhttp://127.0.0.1:<port>. Sinon :error{ code: "forbidden-origin" }puis fermeture 1008. Une connexion sans en-têteOrigin, ou avec une originews://…/wss://…(client natif : IXWebSocket, donc le host, envoie de lui-mêmeOrigin: ws://hôte:port; un navigateur n'envoie jamais ce schéma), est acceptée, sous réserve du jeton.
- les valeurs passées par
Puis la poignée de main (inchangée sur le principe) : le premier message doit être hello.
hello:{ protocolVersion, client }avecclient∈"sequa-studio"(UI) |"sequa-studio-host"(host).welcome:{ protocolVersion, coreVersion, instanceId, pid, linked }instanceId: 32 caractères hexadécimaux aléatoires, tirés au démarrage du Core (identifie CETTE exécution) ;pid: entier, PID du processus Core ;linked: booléen, état du bail (§5).
- Erreurs de poignée de main : comme en 0.1 (
invalid-json,invalid-envelope,unsupported-version,invalid-payload,handshake-required,incompatible-protocol), suivies d'une fermeture.
Après la poignée de main, un message de type inconnu reçoit error { code: "unknown-type" } sans fermeture. Une requête dont le payload est invalide reçoit error { code: "invalid-payload" } sans fermeture.
4. Messages après la poignée de main
Chaque requête reçoit une réponse du même type (avec l'id recopié), ou un error (avec l'id recopié). Payloads :
| Requête | Payload envoyé | Payload de la réponse |
|---|---|---|
core/ping |
{} |
{} |
core/status |
{} |
{ coreVersion, protocolVersion, instanceId, pid, uptimeMs, linked, controllerAttached, clients } |
core/attach |
{ force?: bool } |
{ linked: true } — la connexion devient contrôleur (§5). S'il existe déjà un contrôleur connecté : error controller-exists, sauf force: true (l'ancien perd le rôle). |
core/detach |
{} |
{ linked: false } — lève le bail (§5). Réservé au contrôleur, sinon error not-controller. |
core/shutdown |
{} |
{} envoyé, puis arrêt propre du Core (code de sortie 0) en moins de 2 s. |
session/start |
{ seed: string } |
état de session (ci-dessous). seed : entier 64 bits en hexadécimal (1 à 16 caractères). Remplace la session courante, tick 0, en marche. |
session/pause |
{} |
état de session |
session/resume |
{} |
état de session |
session/status |
{} |
état de session |
session/input |
{ buttons: int, moveX: int, moveY: int } |
{ tick } : tick auquel l'entrée s'appliquera. buttons ∈ [0, 2^32-1], moveX/moveY ∈ [-32767, 32767]. L'entrée vaut pour le prochain tick exécuté ; plusieurs entrées avant ce tick : la dernière gagne. Elle reste ensuite active (entrée maintenue) jusqu'à la suivante. |
session/export |
{ includeHashes?: bool } |
{ recording, tick, stateHash, hashes? } : recording = base64 de sim::serialize(Recording) (format binaire « SQRC » existant) ; hashes (si demandé) = base64 du tableau des empreintes après chaque tick, uint64 petit-boutiste, tick éléments. |
session/import |
{ recording, resume: bool, includeHashes?: bool } |
état de session + { replayMs, hashes? }. Désérialise, rejoue au plus vite (pas en temps réel) tous les ticks enregistrés avec les réglages courants du Core, puis la session continue depuis là ; resume choisit marche ou pause. Enregistrement illisible : error invalid-recording, la session courante est conservée. |
État de session : { seed, tickRateHz, tick, stateHash, running, inputCount } — seed et stateHash en hexadécimal (§2), inputCount = nombre d'entrées non neutres enregistrées.
Le Core n'envoie aucun message spontané en 0.2.
5. Session en direct et bail
- Le Core possède une session en direct : une
sim::Simulationavec sonSessionRecorder, avancée par un fil dédié à la fréquence fixe (60 Hz) viasim::FixedStepClock. Au démarrage : graine--seed(défaut 0), en marche. - Toutes les opérations de session sont sérialisées (verrou) : le fil de simulation et les connexions ne se marchent pas dessus. Les entrées de
session/inputsont enregistrées avec le tick auquel elles s'appliquent : le rejeu est exact. - Bail (mode lié) : lancé avec
--linked, le Core exige un contrôleur.- Si aucun contrôleur ne s'attache dans les 10 s suivant le démarrage, il s'arrête (code 3).
- Si le contrôleur se déconnecte, il s'arrête après un délai de grâce
--lease-grace-ms(défaut 3000) sauf si un contrôleur s'attache entre-temps. core/detachlève le bail : le Core continue sans contrôleur.core/attach(depuis n'importe quelle connexion authentifiée) le rétablit.- Sans
--linked, le Core n'a pas de bail au démarrage (usage autonome) ;core/attachl'active quand même.
6. Options du Core (sequa-core)
--host <adresse> (défaut 127.0.0.1), --port <n> (9100), --token <hex>, --allowed-origin <origine> (répétable), --linked, --lease-grace-ms <n>, --seed <hex>, --help. Codes de sortie : 0 arrêt normal, 1 erreur de démarrage, 2 arguments invalides, 3 bail expiré.
7. Studio host (sequa-studio)
7.1 Options
| Option | Effet |
|---|---|
--core-exe <chemin> |
Exécutable source du Core (défaut : core/sequa-core[.exe] depuis la racine sequa-root.json) |
--core-port <n> |
9100 |
--ui-port <n> |
9180 |
--ui-dir <chemin> |
Dossier de l'interface construite (défaut : studio/ui depuis la racine sequa-root.json) |
--dev [url] |
Ne sert pas l'interface ; ouvre le navigateur sur le serveur Vite (défaut http://localhost:5173) |
--detach-core |
Lance le Core puis lève le bail (core/detach) : il survivra au host |
--no-browser |
N'ouvre pas le navigateur |
--core-console |
Laisse le Core dans sa propre console au lieu de capturer sa sortie |
--watch |
Recharge automatiquement le Core quand son exécutable source change (sinon : simple avertissement) |
7.2 Démarrage ou rattachement
- Lire le fichier d'exécution du port (§7.4). S'il existe : se connecter avec son jeton,
hello; siwelcome.instanceId== celui du fichier → rattachement (core/attach). Sinon le fichier est périmé : le supprimer. - Sinon : copie fantôme (§7.5), lancement du Core avec
--port,--token <nouveau jeton aléatoire 32 hex>,--allowed-origin http://127.0.0.1:<ui-port>ethttp://localhost:<ui-port>(et l'origine Vite en--dev),--linked; attendre que le port accepte les connexions (10 s max),hello,core/attach, écrire le fichier d'exécution. - Servir l'interface (sauf
--dev) et ouvrir le navigateur surhttp://127.0.0.1:<ui-port>/?core=<url du Core encodée, jeton inclus>.
7.3 Commandes (console du host, une par ligne)
status, restart, reload, detach, attach, log (20 dernières lignes du Core), quit (arrête le host ; arrête le Core s'il est lié), help.
7.4 Fichier d'exécution
Chemin : Windows %LOCALAPPDATA%\SEQUA\run\core-<port>.json ; Linux $XDG_RUNTIME_DIR/sequa/core-<port>.json (à défaut ~/.local/state/sequa/run/) ; macOS ~/Library/Application Support/SEQUA/run/. Contenu :
{ "pid": 1234, "host": "127.0.0.1", "port": 9100, "token": "…32 hex…", "instanceId": "…32 hex…",
"exePath": "…copie fantôme…", "sourceExePath": "…build/…/sequa-core.exe…", "startedAt": "2026-11-06T21:00:00Z" }
Écrit par le host après le premier welcome, supprimé quand le Core s'arrête par le host. Contient un secret (jeton) : droits limités à l'utilisateur.
7.5 Copie fantôme
Le Core est lancé depuis <run>/bin/sequa-core-<horodatage UTC compact>-<pid du host>[.exe], copie de l'exécutable source. Au démarrage, le host supprime les copies qui ne sont plus utilisées (suppression qui échoue = copie encore utilisée : ignorer). La journalisation du Core capturée va dans <run>/core-<port>.log (ajout, avec horodatage de démarrage).
7.6 Rechargement (reload, ou --watch quand l'exécutable source change et reste stable 1 s)
session/export { includeHashes: true }sur l'ancien Core →recording,tick,stateHash,hashes; noterrunning.core/shutdown; attendre la fin du processus (3 s), sinon terminaison forcée.- Nouvelle copie fantôme du nouvel exécutable source, lancement (mêmes port et jeton),
hello,core/attach. session/import { recording, resume: <running>, includeHashes: true }.- Comparer les empreintes tick par tick : afficher « identique » ou « divergence au tick N », ainsi que
replayMs. Si les deux tableaux n'ont pas la même longueur, la divergence est au premier tick absent de l'un des deux. - Mettre à jour le fichier d'exécution (nouveau
pid,instanceId,exePath).
restart fait la même chose avec le même exécutable, sans comparaison.
8. Ce qui reste hors 0.3
Messages spontanés du Core (événements), API du host pour l'interface (panneau « Core »), webview, écoute hors poste local.
9. Rendu (ajouté en 0.3)
Studio ne parle jamais au processus de rendu : il demande au Core de le démarrer ou de l'arrêter (ADR 0009, protocole Core - rendu). Requêtes ordinaires (§4) : réponse du même type, id recopié.
| Requête | Payload envoyé | Réponse |
|---|---|---|
render/start |
{ adapter: "godot" | "debug", width?: int, height?: int, headless?: bool, fps?: int } |
état du rendu. Erreurs : render-running (déjà lancé), render-unavailable (sequa-render ou bibliothèque d'adaptateur introuvable, lancement impossible), invalid-payload |
render/stop |
{} |
état du rendu (stopped). Sans erreur si le rendu est déjà arrêté |
render/status |
{} |
état du rendu |
Bornes : width et height dans [64, 7680] (défaut 1280 × 720), fps dans [0, 240] (0 : cadence du moteur). render/start répond dès le lancement du processus (starting) ; Studio interroge ensuite render/status (une fois par seconde suffit) pour voir running.
État du rendu :
{
"state": "stopped" | "starting" | "running" | "restarting",
"adapter": "godot", // absent si stopped
"pid": 1234, // absent si aucun processus
"restarts": 0,
"engine": { "name": "godot", "version": "4.7.2-stable", "buffers": ["color", "depth"], "windowed": true, "headless": false, "playerInput": true },
"width": 1280, "height": 720, // absents tant que le rendu n'est pas prêt
"measurements": { "framesPerSecond": 180, "cpuMsP95": 6.1, "latencyUsP95": 340, "applyUsP95": 5900 },
"worldVersion": "00000000000001d5", // dernière version de scène envoyée
"lastFailure": "adapter-start : …" // absent s'il n'y en a pas eu
}
starting : processus lancé, render/ready pas encore reçu ; running : prêt ; restarting : plantage, relance programmée (protocole Core - rendu §7). engine, width et height viennent de render/ready ; measurements du dernier render/frames.